switchroom 0.17.5 → 0.17.10
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 +39 -5
- package/dist/auth-broker/index.js +386 -208
- package/dist/cli/notion-write-pretool.mjs +36 -3
- package/dist/cli/switchroom.js +1185 -585
- package/dist/host-control/main.js +149 -15
- package/dist/vault/approvals/kernel-server.js +141 -56
- package/dist/vault/broker/server.js +143 -58
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +50 -6
- package/profiles/default/CLAUDE.md +116 -0
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +50 -2
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +2597 -645
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +293 -0
- package/telegram-plugin/gateway/auth-command.ts +1 -1
- package/telegram-plugin/gateway/gateway.ts +1414 -119
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +60 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +153 -4
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
|
@@ -23876,6 +23876,7 @@ function createIpcClient(options) {
|
|
|
23876
23876
|
onInbound,
|
|
23877
23877
|
onPermission,
|
|
23878
23878
|
onStatus,
|
|
23879
|
+
onConnect,
|
|
23879
23880
|
log = () => {},
|
|
23880
23881
|
reconnectDelayMs = 2000,
|
|
23881
23882
|
maxReconnectDelayMs = 30000,
|
|
@@ -24011,6 +24012,13 @@ function createIpcClient(options) {
|
|
|
24011
24012
|
sendRegister();
|
|
24012
24013
|
startHeartbeat();
|
|
24013
24014
|
log(`connected to ${socketPath}`);
|
|
24015
|
+
if (onConnect) {
|
|
24016
|
+
try {
|
|
24017
|
+
onConnect();
|
|
24018
|
+
} catch (err) {
|
|
24019
|
+
log(`onConnect hook threw: ${err}`);
|
|
24020
|
+
}
|
|
24021
|
+
}
|
|
24014
24022
|
resolve();
|
|
24015
24023
|
},
|
|
24016
24024
|
data(sock, data) {
|
|
@@ -24232,10 +24240,52 @@ var init_permission_rule = __esm(() => {
|
|
|
24232
24240
|
]);
|
|
24233
24241
|
});
|
|
24234
24242
|
|
|
24243
|
+
// bridge/permission-ledger.ts
|
|
24244
|
+
function createOutstandingPermissionLedger() {
|
|
24245
|
+
const map2 = new Map;
|
|
24246
|
+
return {
|
|
24247
|
+
add(params) {
|
|
24248
|
+
map2.set(params.request_id, params);
|
|
24249
|
+
},
|
|
24250
|
+
delete(requestId) {
|
|
24251
|
+
map2.delete(requestId);
|
|
24252
|
+
},
|
|
24253
|
+
has(requestId) {
|
|
24254
|
+
return map2.has(requestId);
|
|
24255
|
+
},
|
|
24256
|
+
all() {
|
|
24257
|
+
return [...map2.values()];
|
|
24258
|
+
},
|
|
24259
|
+
get size() {
|
|
24260
|
+
return map2.size;
|
|
24261
|
+
}
|
|
24262
|
+
};
|
|
24263
|
+
}
|
|
24264
|
+
|
|
24235
24265
|
// bridge/bridge.ts
|
|
24236
24266
|
var exports_bridge = {};
|
|
24237
24267
|
import { dirname as dirname3, join as join5 } from "path";
|
|
24238
24268
|
import { homedir as homedir4 } from "os";
|
|
24269
|
+
function flushOutstandingPermissionRequests() {
|
|
24270
|
+
if (!permissionRearmEnabled)
|
|
24271
|
+
return;
|
|
24272
|
+
if (!ipc || !ipc.isConnected())
|
|
24273
|
+
return;
|
|
24274
|
+
const pending = outstandingPermissions.all();
|
|
24275
|
+
if (pending.length === 0)
|
|
24276
|
+
return;
|
|
24277
|
+
process.stderr.write(`telegram bridge: re-sending ${pending.length} outstanding permission request(s) on gateway (re)connect
|
|
24278
|
+
`);
|
|
24279
|
+
for (const p of pending) {
|
|
24280
|
+
ipc.sendPermissionRequest({
|
|
24281
|
+
type: "permission_request",
|
|
24282
|
+
requestId: p.request_id,
|
|
24283
|
+
toolName: p.tool_name,
|
|
24284
|
+
description: p.description,
|
|
24285
|
+
inputPreview: p.input_preview
|
|
24286
|
+
});
|
|
24287
|
+
}
|
|
24288
|
+
}
|
|
24239
24289
|
function onInbound(msg) {
|
|
24240
24290
|
mcp.notification({
|
|
24241
24291
|
method: "notifications/claude/channel",
|
|
@@ -24252,6 +24302,7 @@ function onPermission(msg) {
|
|
|
24252
24302
|
if (msg.rule) {
|
|
24253
24303
|
sessionAllowRules.add(msg.rule);
|
|
24254
24304
|
}
|
|
24305
|
+
outstandingPermissions.delete(msg.requestId);
|
|
24255
24306
|
mcp.notification({
|
|
24256
24307
|
method: "notifications/claude/channel/permission",
|
|
24257
24308
|
params: {
|
|
@@ -24305,6 +24356,7 @@ async function main() {
|
|
|
24305
24356
|
onInbound,
|
|
24306
24357
|
onPermission,
|
|
24307
24358
|
onStatus,
|
|
24359
|
+
onConnect: flushOutstandingPermissionRequests,
|
|
24308
24360
|
log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}
|
|
24309
24361
|
`),
|
|
24310
24362
|
livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join5(STATE_DIR, ".bridge-alive")
|
|
@@ -24318,7 +24370,7 @@ async function main() {
|
|
|
24318
24370
|
}
|
|
24319
24371
|
await mcp.connect(new StdioServerTransport);
|
|
24320
24372
|
}
|
|
24321
|
-
var STATE_DIR, SOCKET_PATH, TOPIC_ID, AGENT_NAME, mcp, TOOL_SCHEMAS, EFFECTIVE_TOOL_SCHEMAS, sessionAllowRules, ipc = null, sessionTailEnabled, sessionTailHandle = null, ptyTailEnabled, ptyTailHandle = null;
|
|
24373
|
+
var STATE_DIR, SOCKET_PATH, TOPIC_ID, AGENT_NAME, mcp, TOOL_SCHEMAS, EFFECTIVE_TOOL_SCHEMAS, sessionAllowRules, outstandingPermissions, permissionRearmEnabled, ipc = null, sessionTailEnabled, sessionTailHandle = null, ptyTailEnabled, ptyTailHandle = null;
|
|
24322
24374
|
var init_bridge = __esm(async () => {
|
|
24323
24375
|
init_server2();
|
|
24324
24376
|
init_stdio2();
|
|
@@ -24681,6 +24733,23 @@ var init_bridge = __esm(async () => {
|
|
|
24681
24733
|
required: ["chat_id", "key"]
|
|
24682
24734
|
}
|
|
24683
24735
|
},
|
|
24736
|
+
{
|
|
24737
|
+
name: "mental_model_propose",
|
|
24738
|
+
description: "Propose a Hindsight MENTAL MODEL for the operator to approve (agent-proposes \u2192 human-approves, hindsight Phase 5). Use this when \u2014 over real work \u2014 you notice a recurring, domain-specific question worth maintaining a standing, semantically-refreshed answer to from YOUR bank (e.g. a coach's `training-plan-state`, a lawyer's `open-matters`). You may PROPOSE but can NEVER self-approve: this renders a Telegram [Approve]/[Deny] card to the operator. On Approve the model is DECLARED \u2014 appended to your `memory.mental_models[]` in switchroom.yaml via the operator-approved config-edit path \u2014 and ensured in your bank (it then refreshes from your bank content). On Deny nothing is written. This is NOT for identity/'who is the user' (dedicated profile banks own that) and NOT a substitute for `retain` (store a fact) or `create_mental_model` where you already have direct Hindsight tools \u2014 it is the leashed, human-gated way to add a DURABLE declared model to your config. After firing this tool, END YOUR TURN cleanly \u2014 a fresh inbound arrives (`<channel source=\"mental_model_proposal_applied\">` / `mental_model_proposal_denied`) once the operator decides. Do NOT propose a model whose name is already declared (it is rejected), and do NOT spam (one card per proposal; the operator sees every one).",
|
|
24739
|
+
inputSchema: {
|
|
24740
|
+
type: "object",
|
|
24741
|
+
properties: {
|
|
24742
|
+
chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
|
|
24743
|
+
name: { type: "string", description: "Stable model name \u2014 the idempotent-ensure identity key (lowercase kebab/snake, e.g. `training-plan-state`). Must be UNIQUE among your already-declared models or the proposal is rejected." },
|
|
24744
|
+
source_query: { type: "string", description: `The reflection query the model answers, semantically refreshed from your bank content (e.g. "What is the athlete's current training plan, recent sessions, and open adjustments?"). Frame it as a DOMAIN question, never an identity question.` },
|
|
24745
|
+
reason: { type: "string", description: 'REQUIRED in practice \u2014 one-line rationale rendered on the card (e.g. "I keep re-deriving the plan state every session; a standing model would save the lookup"). Omitting it renders "why: not provided" and the operator will usually Deny.' },
|
|
24746
|
+
refresh_after_consolidation: { type: "boolean", description: "Refresh this model after each consolidation. Defaults OFF \u2014 refresh adds bounded background model-spend + timeout risk (RFC Phase 5). Only set true when the model genuinely needs to track fast-moving state." },
|
|
24747
|
+
max_tokens: { type: "number", description: "Optional cap on the synthesized model's token size." },
|
|
24748
|
+
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
|
|
24749
|
+
},
|
|
24750
|
+
required: ["chat_id", "name", "source_query"]
|
|
24751
|
+
}
|
|
24752
|
+
},
|
|
24684
24753
|
{
|
|
24685
24754
|
name: "linear_agent_activity",
|
|
24686
24755
|
description: 'Emit a structured Linear AgentActivity against an agent session (#2298). Use this ONLY inside a turn that was woken by a Linear agent session (the inbound carries meta.source="linear" and meta.agent_session_id) \u2014 pass that agent_session_id back here. Linear renders activities as status chips + a timeline on the issue, so the human sees acknowledge \u2192 work \u2192 result. Emit a `thought` within ~10s of being woken so the session does not look dead, then `message`(s) as you make progress, and finally exactly one terminal `complete` (work done) or `error` (you could not proceed). body is required for thought/message/error and optional for complete. Resolves the agent\'s Linear app token from the vault; on VAULT-BROKER-DENIED it returns an error instructing you to vault_request_access for `linear/<agent>/token`.',
|
|
@@ -24804,6 +24873,8 @@ var init_bridge = __esm(async () => {
|
|
|
24804
24873
|
}
|
|
24805
24874
|
});
|
|
24806
24875
|
sessionAllowRules = new Set;
|
|
24876
|
+
outstandingPermissions = createOutstandingPermissionLedger();
|
|
24877
|
+
permissionRearmEnabled = process.env.SWITCHROOM_PERMISSION_REARM !== "0";
|
|
24807
24878
|
mcp.setNotificationHandler(exports_external.object({
|
|
24808
24879
|
method: exports_external.literal("notifications/claude/channel/permission_request"),
|
|
24809
24880
|
params: exports_external.object({
|
|
@@ -24825,9 +24896,22 @@ var init_bridge = __esm(async () => {
|
|
|
24825
24896
|
return;
|
|
24826
24897
|
}
|
|
24827
24898
|
}
|
|
24899
|
+
if (permissionRearmEnabled) {
|
|
24900
|
+
outstandingPermissions.add({
|
|
24901
|
+
request_id: params.request_id,
|
|
24902
|
+
tool_name: params.tool_name,
|
|
24903
|
+
description: params.description,
|
|
24904
|
+
input_preview: params.input_preview
|
|
24905
|
+
});
|
|
24906
|
+
}
|
|
24828
24907
|
if (!ipc || !ipc.isConnected()) {
|
|
24829
|
-
|
|
24908
|
+
if (permissionRearmEnabled) {
|
|
24909
|
+
process.stderr.write(`telegram bridge: permission_request buffered (gateway offline), will re-send on reconnect ` + `request_id=${params.request_id}
|
|
24910
|
+
`);
|
|
24911
|
+
} else {
|
|
24912
|
+
process.stderr.write(`telegram bridge: permission_request received but not connected to gateway
|
|
24830
24913
|
`);
|
|
24914
|
+
}
|
|
24831
24915
|
return;
|
|
24832
24916
|
}
|
|
24833
24917
|
ipc.sendPermissionRequest({
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic silent-turn card climb — the framework owns "alive" and "for how
|
|
3
|
+
* long"; the model owns "what".
|
|
4
|
+
*
|
|
5
|
+
* Implements Phase 1 (the keystone) of `reference/rfcs/deterministic-turn-liveness.md`.
|
|
6
|
+
* Serves `reference/jobs/know-what-my-agent-is-doing.md` (outcome: at any moment
|
|
7
|
+
* during a turn the user can see the agent is alive and how long it has been
|
|
8
|
+
* working, without asking).
|
|
9
|
+
*
|
|
10
|
+
* ## The bug this closes
|
|
11
|
+
*
|
|
12
|
+
* On the 0-label path of `feedHeartbeatTick` (a turn that has surfaced NO tool
|
|
13
|
+
* label — a long single `Bash`, or only suppressed-by-design tools), the tick
|
|
14
|
+
* delegated to `openLivenessFeedIfDue`, whose WHEN-gate `shouldEarlyOpenLiveness`
|
|
15
|
+
* returns `false` the moment a card is already open (`activityMessageId != null`).
|
|
16
|
+
* Result: once the "Working…" card opened, the 0-label branch NEVER edited it
|
|
17
|
+
* again during a silent tool — the card froze on its opening elapsed while the
|
|
18
|
+
* agent was busy for tens of seconds. The only remaining signal was the ambient
|
|
19
|
+
* "stuck" reaction flip, which reads as *done*, not *working*.
|
|
20
|
+
*
|
|
21
|
+
* ## The fix
|
|
22
|
+
*
|
|
23
|
+
* Give the 0-label branch the SAME climbing-elapsed EDIT the labelled branch
|
|
24
|
+
* already performs: when a card is open and `mirrorLines` is empty, re-render the
|
|
25
|
+
* minimal "Working…" card with a fresh wall-clock elapsed on every heartbeat tick.
|
|
26
|
+
* The render is derived PURELY from `ageMs` (`now - turn.startedAt`), so a tool
|
|
27
|
+
* call blocked mid-flight — emitting nothing — cannot starve it. Worst-case gap
|
|
28
|
+
* between visible edits is one to two `FEED_HEARTBEAT_MIN_STALE_MS` windows
|
|
29
|
+
* (~6-12s), versus the observed ~50s freeze.
|
|
30
|
+
*
|
|
31
|
+
* This is a card EDIT, not a text send: it edits the card the chat already owns,
|
|
32
|
+
* and Telegram card edits do not push-notify — squarely the Ambient layer of
|
|
33
|
+
* `reference/rfcs/conversational-pacing.md`, crossing no invariant (no parallel
|
|
34
|
+
* surface, no mid-turn device buzz). When the model DOES narrate, its words land
|
|
35
|
+
* on the same card chronologically (`mirrorLines` becomes non-empty and the
|
|
36
|
+
* labelled-feed heartbeat takes over) — the climb fills only the gaps the model
|
|
37
|
+
* leaves and never overwrites narration.
|
|
38
|
+
*
|
|
39
|
+
* Kept as a small pure function so the guarantee can be proven at the transport
|
|
40
|
+
* boundary (Phase 4b regression test) rather than by grepping the gateway source.
|
|
41
|
+
*/
|
|
42
|
+
import {
|
|
43
|
+
renderActivityFeedWithNested,
|
|
44
|
+
formatStepSuffix,
|
|
45
|
+
type SessionActivityHeader,
|
|
46
|
+
} from './tool-activity-summary.js'
|
|
47
|
+
|
|
48
|
+
/** The minimal placeholder line the silent-turn card carries when no tool label
|
|
49
|
+
* has surfaced. Matches `openLivenessFeedIfDue`'s open render so the climb edit
|
|
50
|
+
* is a clean continuation of the placeholder the OPEN path produced. */
|
|
51
|
+
export const SILENT_TURN_PLACEHOLDER = 'Working…'
|
|
52
|
+
|
|
53
|
+
export interface SilentTurnClimbInput {
|
|
54
|
+
/** `turn.mirrorLines.length`. 0 ⇒ no tool label has surfaced this turn (pure
|
|
55
|
+
* thinking / only suppressed tools) — the case this climb owns. >0 ⇒ a real
|
|
56
|
+
* label drives the labelled-feed heartbeat, which owns the climb instead. */
|
|
57
|
+
mirrorLineCount: number
|
|
58
|
+
/** `turn.activityMessageId`. `null` ⇒ no card is open yet, so the OPEN path
|
|
59
|
+
* (`openLivenessFeedIfDue`) owns this tick; non-null ⇒ a card is open and this
|
|
60
|
+
* branch must keep it climbing (the exact freeze this fix closes). */
|
|
61
|
+
activityMessageId: number | null
|
|
62
|
+
/** `turn.labeledToolCount` — surfaced in the card header's tool count. */
|
|
63
|
+
labeledToolCount: number
|
|
64
|
+
/** `now - turn.startedAt`: wall-clock elapsed since turn start. The single
|
|
65
|
+
* signal the model can never supply while blocked inside a tool. */
|
|
66
|
+
ageMs: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The deterministic 0-label climb render. Returns the card body HTML to EDIT the
|
|
71
|
+
* already-open "Working…" card with a climbing wall-clock elapsed, or `null` when
|
|
72
|
+
* this branch must NOT edit:
|
|
73
|
+
* - `mirrorLineCount > 0` — a tool label surfaced; the labelled-feed heartbeat
|
|
74
|
+
* owns the climb (its edit cleanly replaces this placeholder).
|
|
75
|
+
* - `activityMessageId == null` — no card is open yet; the OPEN path
|
|
76
|
+
* (`openLivenessFeedIfDue`) owns this tick.
|
|
77
|
+
*
|
|
78
|
+
* The header's `elapsedMs` carries the climbing clock, so each tick's edit shows
|
|
79
|
+
* a higher "Working · Ns" than the last. Model-independent by construction: it
|
|
80
|
+
* reads only wall-clock `ageMs`.
|
|
81
|
+
*/
|
|
82
|
+
export function silentTurnClimbRender(input: SilentTurnClimbInput): string | null {
|
|
83
|
+
if (input.mirrorLineCount > 0) return null
|
|
84
|
+
if (input.activityMessageId == null) return null
|
|
85
|
+
const header: SessionActivityHeader = {
|
|
86
|
+
label: 'Agent',
|
|
87
|
+
elapsedMs: input.ageMs,
|
|
88
|
+
toolCount: input.labeledToolCount,
|
|
89
|
+
state: 'running',
|
|
90
|
+
}
|
|
91
|
+
// Single "step" whose start is the turn start, so `ageMs` IS the step's own
|
|
92
|
+
// elapsed. formatStepSuffix keeps the `→` line timer-free until STEP_TIMER_MIN_MS;
|
|
93
|
+
// the header total still climbs every tick — that is the visible clock.
|
|
94
|
+
return renderActivityFeedWithNested(
|
|
95
|
+
[SILENT_TURN_PLACEHOLDER],
|
|
96
|
+
[],
|
|
97
|
+
false,
|
|
98
|
+
formatStepSuffix(input.ageMs),
|
|
99
|
+
undefined,
|
|
100
|
+
header,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The per-turn view `runSilentTurnHeartbeatTick` reads — a structural subset of
|
|
106
|
+
* the gateway's `CurrentTurn`, kept transport-free so this module (and its test)
|
|
107
|
+
* never imports the gateway.
|
|
108
|
+
*/
|
|
109
|
+
export interface SilentTurnTickView {
|
|
110
|
+
/** `turn.mirrorLines.length` at tick time. */
|
|
111
|
+
mirrorLineCount: number
|
|
112
|
+
/** `turn.activityMessageId` at tick time (null ⇒ no card open yet). */
|
|
113
|
+
activityMessageId: number | null
|
|
114
|
+
/** `turn.labeledToolCount` — surfaced in the card header. */
|
|
115
|
+
labeledToolCount: number
|
|
116
|
+
/** `now - turn.startedAt` at tick time. */
|
|
117
|
+
ageMs: number
|
|
118
|
+
/** The labelled branch's stale floor (`FEED_HEARTBEAT_MIN_STALE_MS`). The
|
|
119
|
+
* climb applies the SAME guard for symmetry: a turn younger than one stale
|
|
120
|
+
* window is fresh (its card just opened ~1.2s in), so no climb edit yet.
|
|
121
|
+
* Without this the climb relied on the tick interval == the stale window
|
|
122
|
+
* (both 6s today) — an implicit coupling this guard makes explicit. */
|
|
123
|
+
minStaleMs: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The gateway seams the tick drives, injected as thunks so THIS function — the
|
|
128
|
+
* shipped 0-label branch of `feedHeartbeatTick` — is directly executable in a
|
|
129
|
+
* test with spies at each seam. The gateway wires the real implementations
|
|
130
|
+
* (`openLivenessFeedIfDue`, `turn.activityPendingRender = …`, `cardDrainGate`,
|
|
131
|
+
* `ea.mayDrain`, `ea.openOrEditCard('liveness', …)`,
|
|
132
|
+
* `drainActivitySummary(turn, 'liveness')`); the wiring is pinned structurally
|
|
133
|
+
* in tests/feed-heartbeat-liveness-open.test.ts.
|
|
134
|
+
*/
|
|
135
|
+
export interface SilentTurnTickDeps {
|
|
136
|
+
/** The one shared OPEN path (`openLivenessFeedIfDue(turn)`). Owns the open;
|
|
137
|
+
* the climb never opens (reply-is-last gating lives on the open path). */
|
|
138
|
+
openLivenessFeedIfDue: () => void
|
|
139
|
+
/** Stage the climb render (`turn.activityPendingRender = rendered`). */
|
|
140
|
+
setPendingRender: (rendered: string) => void
|
|
141
|
+
/** The centralized chatLock-serialized card-drain gate
|
|
142
|
+
* (`cardDrainGate(turn, ea, run)`). */
|
|
143
|
+
cardDrainGate: (run: () => void) => void
|
|
144
|
+
/** The single-flight pure read (`ea.mayDrain(turn)`). */
|
|
145
|
+
mayDrain: () => boolean
|
|
146
|
+
/** The emission-authority façade routing (`ea.openOrEditCard('liveness', apply)`).
|
|
147
|
+
* With a card open this only ever EDITs — Telegram edits don't push-notify. */
|
|
148
|
+
openOrEditCard: (apply: () => void) => void
|
|
149
|
+
/** The drain kick-off (`turn.activityInFlight = drainActivitySummary(turn,
|
|
150
|
+
* 'liveness')`) — the transport writer (sendMessage / editMessageText via
|
|
151
|
+
* robustApiCall) lives behind this seam. */
|
|
152
|
+
drain: () => void
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The REAL 0-label branch of `feedHeartbeatTick` (deterministic-turn-liveness.md
|
|
157
|
+
* Phase 1) — extracted here so the shipped decision logic is directly under
|
|
158
|
+
* outcome-based test (`tests/silent-turn-climb-transport.test.ts`); the gateway
|
|
159
|
+
* IIFE cannot be imported in-process, so the tick body lives in this module and
|
|
160
|
+
* the gateway calls it with its real deps.
|
|
161
|
+
*
|
|
162
|
+
* Returns true when the tick was handled by the 0-label path (the caller must
|
|
163
|
+
* stop — the labelled-feed heartbeat does not run), false when a tool label has
|
|
164
|
+
* surfaced (`mirrorLineCount > 0`) and the labelled branch owns the tick.
|
|
165
|
+
*
|
|
166
|
+
* Behaviour:
|
|
167
|
+
* - no card open yet → delegate to the one shared OPEN path (never opens here);
|
|
168
|
+
* - card open + turn younger than `minStaleMs` → fresh, no edit yet (the same
|
|
169
|
+
* stale-guard the labelled branch applies to its step elapsed);
|
|
170
|
+
* - card open + stale → stage the climbing "Working · Ns" render and EDIT it
|
|
171
|
+
* through the same cardDrainGate → mayDrain → openOrEditCard → drain gate
|
|
172
|
+
* sequence the labelled branch uses. Deterministic + framework-owned: reads
|
|
173
|
+
* only wall-clock `ageMs`, so a blocked tool call cannot starve it.
|
|
174
|
+
*/
|
|
175
|
+
export function runSilentTurnHeartbeatTick(
|
|
176
|
+
view: SilentTurnTickView,
|
|
177
|
+
deps: SilentTurnTickDeps,
|
|
178
|
+
): boolean {
|
|
179
|
+
if (view.mirrorLineCount !== 0) return false
|
|
180
|
+
if (view.activityMessageId == null) {
|
|
181
|
+
deps.openLivenessFeedIfDue()
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
// Stale-guard (symmetry with the labelled branch's `elapsed <
|
|
185
|
+
// FEED_HEARTBEAT_MIN_STALE_MS` check): the 0-label "step" starts at turn
|
|
186
|
+
// start, so `ageMs` IS its elapsed. A younger turn's card just opened.
|
|
187
|
+
if (view.ageMs < view.minStaleMs) return true
|
|
188
|
+
const rendered = silentTurnClimbRender({
|
|
189
|
+
mirrorLineCount: view.mirrorLineCount,
|
|
190
|
+
activityMessageId: view.activityMessageId,
|
|
191
|
+
labeledToolCount: view.labeledToolCount,
|
|
192
|
+
ageMs: view.ageMs,
|
|
193
|
+
})
|
|
194
|
+
if (rendered == null) return true
|
|
195
|
+
deps.setPendingRender(rendered)
|
|
196
|
+
deps.cardDrainGate(() => {
|
|
197
|
+
if (deps.mayDrain()) {
|
|
198
|
+
// Maintains an already-open card (guarded above on activityMessageId !=
|
|
199
|
+
// null) → only ever EDITs. 'liveness' producer is correct.
|
|
200
|
+
deps.openOrEditCard(() => {
|
|
201
|
+
deps.drain()
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* activity-card-store.ts — durable handle for the mid-turn activity card, so a
|
|
3
|
+
* gateway restart mid-turn can finalize the orphaned card instead of leaving it
|
|
4
|
+
* frozen forever.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists (reference/rfcs/deterministic-turn-liveness.md Known Gap 1):
|
|
7
|
+
* `CurrentTurn.activityMessageId` / `startedAt` and the live `currentTurn`
|
|
8
|
+
* itself live ONLY in gateway memory. If the gateway restarts while a turn's
|
|
9
|
+
* activity card is open, the new process has no handle to the old card — it
|
|
10
|
+
* is never edited again and sits frozen on its last pre-restart text forever.
|
|
11
|
+
* The resumed turn (if any) opens a FRESH card; the old one is orphaned.
|
|
12
|
+
*
|
|
13
|
+
* The fix mirrors `status-pin-store.ts` byte-for-byte in shape: a tiny durable
|
|
14
|
+
* snapshot on the per-agent state volume (STATE_DIR), written whenever a card
|
|
15
|
+
* OPENS, cleared the moment it closes/finalizes normally. On boot, AFTER this
|
|
16
|
+
* gateway wins the startup mutex (same ordering constraint as the status-pin
|
|
17
|
+
* cleanup — this is a shared per-agent file, so a losing double-boot must
|
|
18
|
+
* never touch it), a one-shot reaper reads any leftover record and finalizes
|
|
19
|
+
* the orphaned card with a SINGLE honest edit (never a new message — edits do
|
|
20
|
+
* not notify, so no ping). We do NOT attempt to resume climbing the old card
|
|
21
|
+
* across the restart; the resumed turn (if any) owns a fresh card. Honest
|
|
22
|
+
* finalization — not resumption — is the goal.
|
|
23
|
+
*
|
|
24
|
+
* Crash-safety: write-tmp + atomic rename (identical convention to
|
|
25
|
+
* status-pin-store.ts / obligation-store.ts). A corrupt or unreadable file
|
|
26
|
+
* fails open to `[]` — never crashes boot, worst case an orphan isn't cleaned
|
|
27
|
+
* up this boot, no worse than pre-fix behaviour.
|
|
28
|
+
*
|
|
29
|
+
* DELIVERED GUARANTEE — AT-MOST-ONCE finalization (be honest about it):
|
|
30
|
+
* the reaper deletes a record from the on-disk store BEFORE attempting its
|
|
31
|
+
* finalizing edit (not after) and swallows every Telegram failure with no
|
|
32
|
+
* retry. That ordering buys strict idempotency — a crash mid-reap, or a second
|
|
33
|
+
* boot re-reading the file, can never double-edit or double-unpin the same
|
|
34
|
+
* message — at the cost of durability: if the single edit fails (flood-wait
|
|
35
|
+
* exhausted, transient 5xx, chat momentarily unreachable), the orphaned card is
|
|
36
|
+
* FORFEIT and stays frozen, because its record is already gone. So the promise
|
|
37
|
+
* is NOT "finalized on the very next boot" — it is "finalized AT MOST ONCE, on
|
|
38
|
+
* the next boot, best-effort". We deliberately chose at-most-once over
|
|
39
|
+
* at-least-once: a retry-until-success design cannot preserve the
|
|
40
|
+
* second-boot-zero-edits property across a crash between a successful edit and
|
|
41
|
+
* its post-edit delete (that window would re-edit an already-finalized card),
|
|
42
|
+
* and a stale re-finalize on a chat the user has since moved on from is a worse
|
|
43
|
+
* failure than one frozen orphan. A benign-400 (message already
|
|
44
|
+
* deleted/vanished, or "not modified") is NOT counted as a finalize — nothing
|
|
45
|
+
* was delivered — see the `vanished` tally in `runActivityCardBootReaper`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface ActivityCardStoreFsSeam {
|
|
49
|
+
readFileSync: (path: string) => string
|
|
50
|
+
writeFileSync: (path: string, data: string) => void
|
|
51
|
+
/** Atomic same-dir replace (POSIX rename) so a crash mid-write can't tear
|
|
52
|
+
* the snapshot. */
|
|
53
|
+
renameSync: (from: string, to: string) => void
|
|
54
|
+
existsSync: (path: string) => boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One persisted card handle: enough to identify and finalize the orphaned
|
|
58
|
+
* Telegram message on a future boot, without any live turn state. */
|
|
59
|
+
export interface ActivityCardRecord {
|
|
60
|
+
/** Stable per-topic key (`statusKey(chatId, threadId)` shape) — also the
|
|
61
|
+
* upsert key: a fresh OPEN for the same topic replaces any stale row. */
|
|
62
|
+
turnKey: string
|
|
63
|
+
chatId: string
|
|
64
|
+
threadId: number | null
|
|
65
|
+
activityMessageId: number
|
|
66
|
+
/** Wall-clock ms the turn started — carried through so the finalize text
|
|
67
|
+
* can be honest about elapsed time even after a restart. */
|
|
68
|
+
startedAt: number
|
|
69
|
+
/** Whether this card was silently pinned when it opened (the gateway pins
|
|
70
|
+
* every fresh activity card via `reconcileStatusPin('fg:'+turnKey, …)`).
|
|
71
|
+
* The boot reaper must unpin it too — a frozen card that's ALSO still
|
|
72
|
+
* pinned is a worse orphan (it stays glued to the top of the chat
|
|
73
|
+
* forever). Optional so a v1-shape record (pre-unpin-tracking) still
|
|
74
|
+
* loads and degrades to "don't attempt an unpin", never a crash. */
|
|
75
|
+
pinned?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface SnapshotEnvelope {
|
|
79
|
+
v: 1
|
|
80
|
+
cards: ActivityCardRecord[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isCardRow(x: unknown): x is ActivityCardRecord {
|
|
84
|
+
if (x == null || typeof x !== 'object') return false
|
|
85
|
+
const o = x as Record<string, unknown>
|
|
86
|
+
return (
|
|
87
|
+
typeof o.turnKey === 'string' &&
|
|
88
|
+
o.turnKey.length > 0 &&
|
|
89
|
+
typeof o.chatId === 'string' &&
|
|
90
|
+
o.chatId.length > 0 &&
|
|
91
|
+
(o.threadId === null || typeof o.threadId === 'number') &&
|
|
92
|
+
typeof o.activityMessageId === 'number' &&
|
|
93
|
+
typeof o.startedAt === 'number' &&
|
|
94
|
+
(o.pinned === undefined || typeof o.pinned === 'boolean')
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load the persisted card-handle set. Returns [] on a missing, unreadable, or
|
|
100
|
+
* malformed file — fail-open, never throws.
|
|
101
|
+
*/
|
|
102
|
+
export function loadActivityCards(
|
|
103
|
+
path: string,
|
|
104
|
+
fs: ActivityCardStoreFsSeam,
|
|
105
|
+
): ActivityCardRecord[] {
|
|
106
|
+
if (!fs.existsSync(path)) return []
|
|
107
|
+
let raw = ''
|
|
108
|
+
try {
|
|
109
|
+
raw = fs.readFileSync(path)
|
|
110
|
+
} catch {
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
let parsed: unknown
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(raw)
|
|
116
|
+
} catch {
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
if (parsed == null || typeof parsed !== 'object') return []
|
|
120
|
+
const env = parsed as Record<string, unknown>
|
|
121
|
+
if (env.v !== 1 || !Array.isArray(env.cards)) return []
|
|
122
|
+
return env.cards.filter(isCardRow)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Persist the card-handle set atomically (write sibling tmp → rename). Never
|
|
127
|
+
* throws — a write failure is logged and the store degrades to in-memory-only
|
|
128
|
+
* for this process (same contract as `status-pin-store.ts`).
|
|
129
|
+
*/
|
|
130
|
+
export function persistActivityCards(
|
|
131
|
+
path: string,
|
|
132
|
+
fs: ActivityCardStoreFsSeam,
|
|
133
|
+
snapshot: readonly ActivityCardRecord[],
|
|
134
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
135
|
+
): void {
|
|
136
|
+
const env: SnapshotEnvelope = { v: 1, cards: [...snapshot] }
|
|
137
|
+
const tmp = path + '.tmp'
|
|
138
|
+
try {
|
|
139
|
+
fs.writeFileSync(tmp, JSON.stringify(env))
|
|
140
|
+
fs.renameSync(tmp, path)
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log(
|
|
143
|
+
`activity-card-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
|
|
144
|
+
`durability degraded to in-memory\n`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Upsert the card-handle row for `turnKey` (replacing any stale row for the
|
|
151
|
+
* same topic — e.g. a prior turn's card that was never cleared). Called the
|
|
152
|
+
* moment a card OPENs (activityMessageId transitions null → set).
|
|
153
|
+
*/
|
|
154
|
+
export function writeActivityCardRecord(
|
|
155
|
+
path: string,
|
|
156
|
+
fs: ActivityCardStoreFsSeam,
|
|
157
|
+
record: ActivityCardRecord,
|
|
158
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
159
|
+
): void {
|
|
160
|
+
const current = loadActivityCards(path, fs)
|
|
161
|
+
const others = current.filter((c) => c.turnKey !== record.turnKey)
|
|
162
|
+
persistActivityCards(path, fs, [...others, record], log)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Remove the card-handle row for `turnKey`, if present. Called the moment a
|
|
167
|
+
* card closes/finalizes normally (clearActivitySummary), and by the boot
|
|
168
|
+
* reaper (BEFORE attempting the finalizing edit — the idempotency guard).
|
|
169
|
+
*
|
|
170
|
+
* REAP-RACE guard: when `activityMessageId` is supplied, only a row matching
|
|
171
|
+
* BOTH `turnKey` AND `activityMessageId` is removed. During a slow multi-record
|
|
172
|
+
* reap (e.g. a 429 flood-wait between records), a fresh live turn can upsert a
|
|
173
|
+
* NEW record under the same `turnKey` (its own, different `activityMessageId`).
|
|
174
|
+
* A turnKey-only clear would then delete the LIVE card's durable protection.
|
|
175
|
+
* Scoping the clear to the exact message id the reaper (or the closing turn) is
|
|
176
|
+
* handling leaves the live card's record intact. Omit `activityMessageId` only
|
|
177
|
+
* when you genuinely mean "clear whatever row exists for this topic".
|
|
178
|
+
*/
|
|
179
|
+
export function clearActivityCardRecord(
|
|
180
|
+
path: string,
|
|
181
|
+
fs: ActivityCardStoreFsSeam,
|
|
182
|
+
turnKey: string,
|
|
183
|
+
activityMessageId?: number,
|
|
184
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
185
|
+
): void {
|
|
186
|
+
const current = loadActivityCards(path, fs)
|
|
187
|
+
if (current.length === 0) return
|
|
188
|
+
const next = current.filter(
|
|
189
|
+
(c) =>
|
|
190
|
+
c.turnKey !== turnKey ||
|
|
191
|
+
(activityMessageId !== undefined && c.activityMessageId !== activityMessageId),
|
|
192
|
+
)
|
|
193
|
+
if (next.length === current.length) return
|
|
194
|
+
persistActivityCards(path, fs, next, log)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Boot-time orphan reaper, extracted as a pure routine over injected seams
|
|
199
|
+
* (mirrors `runStatusPinBootCleanup`). The gateway's thin wrapper binds the
|
|
200
|
+
* live fs, a Telegram edit call, and the logger.
|
|
201
|
+
*
|
|
202
|
+
* CRITICAL ordering, identical constraint to the status-pin cleanup: the
|
|
203
|
+
* caller MUST only invoke this AFTER winning the startup mutex. The store is
|
|
204
|
+
* a shared per-agent file; a losing double-boot running this would finalize
|
|
205
|
+
* cards the still-alive holder's in-flight turn still legitimately owns.
|
|
206
|
+
*
|
|
207
|
+
* Deletes each record from disk BEFORE attempting its edit/unpin (not
|
|
208
|
+
* after), so a crash mid-reap, or a second boot re-reading the file, can
|
|
209
|
+
* never re-finalize (and thus never double-edit, never double-unpin) the
|
|
210
|
+
* same message. `finalizeCard` / `unpinCard` failures (message already
|
|
211
|
+
* deleted, chat unreachable, permissions changed, already-unpinned, etc.)
|
|
212
|
+
* are swallowed — non-fatal, logged, and never re-attempted since the
|
|
213
|
+
* record is already gone.
|
|
214
|
+
*
|
|
215
|
+
* Unpin runs AFTER the finalizing edit, only for a record with `pinned:
|
|
216
|
+
* true` (a v1-shape record with no `pinned` field is left un-unpinned —
|
|
217
|
+
* conservative degrade, not a crash), and its failure is independent of the
|
|
218
|
+
* edit's outcome (an edit failure — e.g. message already deleted — must not
|
|
219
|
+
* skip the unpin attempt, since Telegram allows unpinning a deleted
|
|
220
|
+
* message's id to no-op harmlessly, and conversely an unpin failure must
|
|
221
|
+
* never re-attempt the edit).
|
|
222
|
+
*/
|
|
223
|
+
export async function runActivityCardBootReaper(args: {
|
|
224
|
+
path: string
|
|
225
|
+
fs: ActivityCardStoreFsSeam
|
|
226
|
+
/** Returns the Telegram result: a truthy value (the edited Message) on a
|
|
227
|
+
* real delivered edit, or `null`/`undefined` when the underlying
|
|
228
|
+
* `robustApiCall` swallowed a benign 400 (message already deleted /
|
|
229
|
+
* vanished / "not modified") — i.e. nothing was actually finalized. */
|
|
230
|
+
finalizeCard: (record: ActivityCardRecord) => Promise<unknown>
|
|
231
|
+
unpinCard: (record: ActivityCardRecord) => Promise<unknown>
|
|
232
|
+
log?: (line: string) => void
|
|
233
|
+
}): Promise<{ finalized: number; vanished: number; unpinned: number; total: number }> {
|
|
234
|
+
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
235
|
+
const persisted = loadActivityCards(args.path, args.fs)
|
|
236
|
+
if (persisted.length === 0) return { finalized: 0, vanished: 0, unpinned: 0, total: 0 }
|
|
237
|
+
let finalized = 0
|
|
238
|
+
let vanished = 0
|
|
239
|
+
let unpinned = 0
|
|
240
|
+
for (const record of persisted) {
|
|
241
|
+
// Delete BEFORE attempting the edit/unpin — the idempotency guard. If the
|
|
242
|
+
// gateway crashes here, or a second boot re-reads the file, this record
|
|
243
|
+
// no longer exists on disk, so it can never be finalized or unpinned
|
|
244
|
+
// twice. Scoped to the exact activityMessageId (reap-race guard): a live
|
|
245
|
+
// turn that upserted a fresh card under the same turnKey mid-reap keeps
|
|
246
|
+
// its own (different-id) record.
|
|
247
|
+
clearActivityCardRecord(args.path, args.fs, record.turnKey, record.activityMessageId, log)
|
|
248
|
+
try {
|
|
249
|
+
// Count a finalize ONLY when the edit actually landed. robustApiCall
|
|
250
|
+
// resolves to undefined on a benign-400 (the card was already deleted or
|
|
251
|
+
// the chat is gone) — that delivered nothing, so it is a `vanished`
|
|
252
|
+
// orphan, not a `finalized` one. Counting it as finalized (the pre-fix
|
|
253
|
+
// behaviour) over-reported the guarantee in the boot log.
|
|
254
|
+
const res = await args.finalizeCard(record)
|
|
255
|
+
if (res != null) finalized++
|
|
256
|
+
else vanished++
|
|
257
|
+
} catch (err) {
|
|
258
|
+
log(
|
|
259
|
+
`activity-card-store: boot reaper finalize failed ` +
|
|
260
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}): ` +
|
|
261
|
+
`${(err as Error).message}\n`,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
if (record.pinned) {
|
|
265
|
+
try {
|
|
266
|
+
await args.unpinCard(record)
|
|
267
|
+
unpinned++
|
|
268
|
+
} catch (err) {
|
|
269
|
+
log(
|
|
270
|
+
`activity-card-store: boot reaper unpin failed ` +
|
|
271
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}): ` +
|
|
272
|
+
`${(err as Error).message}\n`,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { finalized, vanished, unpinned, total: persisted.length }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Honest finalize text for an orphaned card — the single edit the boot
|
|
282
|
+
* reaper applies. Mirrors `silentEndFallbackText`'s honesty-about-elapsed
|
|
283
|
+
* convention (`telegram-plugin/silent-end.ts`): degenerate/unknown durations
|
|
284
|
+
* omit the elapsed clause rather than printing a nonsensical "(0s)".
|
|
285
|
+
*/
|
|
286
|
+
export function restartOrphanCardFinalizeText(startedAt: number): string {
|
|
287
|
+
const elapsedMs = Date.now() - startedAt
|
|
288
|
+
const elapsed =
|
|
289
|
+
Number.isFinite(elapsedMs) && elapsedMs > 0
|
|
290
|
+
? ` (was running ${Math.round(elapsedMs / 1000)}s)`
|
|
291
|
+
: ''
|
|
292
|
+
return `⚠️ Interrupted by a gateway restart${elapsed} — this turn did not finish.`
|
|
293
|
+
}
|
|
@@ -403,7 +403,7 @@ export async function handleAuthCommand(
|
|
|
403
403
|
let keyboard: AuthCommandReply['keyboard']
|
|
404
404
|
if (liveQuotas && liveQuotas.length === state.accounts.length) {
|
|
405
405
|
const snapshots = buildSnapshotsFromState(state, liveQuotas)
|
|
406
|
-
keyboard = buildSnapshotKeyboard(snapshots, { now: new Date() })
|
|
406
|
+
keyboard = buildSnapshotKeyboard(snapshots, { now: new Date(), demo: ctx.demo })
|
|
407
407
|
}
|
|
408
408
|
return {
|
|
409
409
|
text: renderShowText(state, Date.now(), {
|