switchroom 0.19.39 → 0.19.41
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 +10 -1
- package/dist/auth-broker/index.js +12 -3
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +562 -196
- package/dist/host-control/main.js +287 -20
- package/dist/vault/approvals/kernel-server.js +12 -3
- package/dist/vault/broker/server.js +12 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +10 -2
- package/telegram-plugin/dist/gateway/gateway.js +549 -232
- package/telegram-plugin/dist/server.js +10 -2
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +72 -72
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +39 -1
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
|
@@ -17627,6 +17627,14 @@ function projectTranscriptLine(line) {
|
|
|
17627
17627
|
{ kind: "turn_end", durationMs: obj.durationMs ?? 0 }
|
|
17628
17628
|
];
|
|
17629
17629
|
}
|
|
17630
|
+
if (type === "system" && obj.subtype === "compact_boundary") {
|
|
17631
|
+
const meta = obj.compactMetadata;
|
|
17632
|
+
return [{
|
|
17633
|
+
kind: "compact_boundary",
|
|
17634
|
+
trigger: typeof meta?.trigger === "string" ? meta.trigger : null,
|
|
17635
|
+
compactDurationMs: typeof meta?.durationMs === "number" ? meta.durationMs : null
|
|
17636
|
+
}];
|
|
17637
|
+
}
|
|
17630
17638
|
return [];
|
|
17631
17639
|
}
|
|
17632
17640
|
function projectSubagentLine(line, agentId, state) {
|
|
@@ -24999,7 +25007,7 @@ var init_bridge = __esm(async () => {
|
|
|
24999
25007
|
},
|
|
25000
25008
|
{
|
|
25001
25009
|
name: "send_checklist",
|
|
25002
|
-
description: 'Send a
|
|
25010
|
+
description: 'Send a checklist (task list) to a chat. Native interactive Telegram checklists require a Business-account connection, which most deployments do not have \u2014 in that normal case the checklist is sent as a formatted TEXT message (bold title + \u2705/\u2b1c task lines) and the result carries degraded:"text": tasks are NOT tappable, and you tick them yourself via update_checklist (task ids are 1..N in send order). With a Business connection configured, the checklist is sent natively and tick events arrive as channel events with kind="checklist_task_changed". Returns JSON with message_id and mode. Limit: 30 tasks.',
|
|
25003
25011
|
inputSchema: {
|
|
25004
25012
|
type: "object",
|
|
25005
25013
|
properties: {
|
|
@@ -25084,7 +25092,7 @@ var init_bridge = __esm(async () => {
|
|
|
25084
25092
|
},
|
|
25085
25093
|
{
|
|
25086
25094
|
name: "update_checklist",
|
|
25087
|
-
description:
|
|
25095
|
+
description: `Patch a checklist previously sent with send_checklist: update the title, append tasks, or mark tasks done/undone. Tasks with an id target existing items (ids are 1..N in send order for text-mode checklists); tasks without an id are appended. Removal is not supported. The edit re-renders in the mode the checklist was sent in (text for most deployments). Returns JSON { ok, ... }; after a gateway restart the stored task state may be gone \u2014 then it returns ok:false with reason "unknown_checklist" unless you pass a full replacement (title + every task's text).`,
|
|
25088
25096
|
inputSchema: {
|
|
25089
25097
|
type: "object",
|
|
25090
25098
|
properties: {
|
|
@@ -322,6 +322,28 @@ export interface BackstopDeliveryDeps {
|
|
|
322
322
|
messageIds: readonly number[],
|
|
323
323
|
text: string,
|
|
324
324
|
) => Promise<ReadBackResult>
|
|
325
|
+
/**
|
|
326
|
+
* Exactly-once SEND-ACK claim (duplicate-message race fix). Fired EXACTLY ONCE,
|
|
327
|
+
* the first moment every chunk has landed a fresh non-card receipt — i.e. the
|
|
328
|
+
* same condition that makes {@link BackstopDeliveryResult.delivered} true — and
|
|
329
|
+
* crucially BEFORE the read-back probe (step 2 below) is awaited.
|
|
330
|
+
*
|
|
331
|
+
* Why before the probe: the probe is issued at COSMETIC priority and, when the
|
|
332
|
+
* edit-flood-fuse is deferring cosmetic edits, `await deps.readBack` can block
|
|
333
|
+
* ~30s. The caller's durable exactly-once claim (`journalExternalDelivery`)
|
|
334
|
+
* used to run only in its post-`await deliverAnswer` bookkeeping, i.e. AFTER
|
|
335
|
+
* that probe resolved. The out-of-band outbox sweep only waits `OUTBOX_QUIET_MS`
|
|
336
|
+
* (5s) before checking the delivered-keys journal, so during that 5–30s gap it
|
|
337
|
+
* saw no journal entry and sent a SECOND copy of the same answer. Claiming here,
|
|
338
|
+
* at send-ack, closes that window deterministically regardless of probe timing.
|
|
339
|
+
*
|
|
340
|
+
* A later `absent` read-back (positive silent-drop) is recovered by THIS
|
|
341
|
+
* orchestrator's own in-process re-send below — the nonce is NEVER reopened to
|
|
342
|
+
* the sweep, so a possibly-delivered answer is never handed to a second sender.
|
|
343
|
+
* `sentIds` is the landed fresh-receipt set at claim time. Best-effort; a throw
|
|
344
|
+
* is swallowed and never demotes the delivery.
|
|
345
|
+
*/
|
|
346
|
+
onAckClaim?: (sentIds: number[]) => void
|
|
325
347
|
/** Optional stderr sink for progress/resume logging. */
|
|
326
348
|
stderr?: (s: string) => void
|
|
327
349
|
}
|
|
@@ -406,6 +428,8 @@ export async function runBackstopDelivery(
|
|
|
406
428
|
const stderr = deps.stderr ?? (() => {})
|
|
407
429
|
const chunkCount = chunks.length
|
|
408
430
|
let attempts = 0
|
|
431
|
+
// Fires the send-ack claim (see BackstopDeliveryDeps.onAckClaim) at most once.
|
|
432
|
+
let ackClaimed = false
|
|
409
433
|
|
|
410
434
|
for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) {
|
|
411
435
|
attempts = attempt
|
|
@@ -437,6 +461,30 @@ export async function runBackstopDelivery(
|
|
|
437
461
|
}
|
|
438
462
|
}
|
|
439
463
|
|
|
464
|
+
// 1b. EXACTLY-ONCE SEND-ACK CLAIM (duplicate-message race fix). The instant
|
|
465
|
+
// every chunk has landed a fresh non-card receipt, fire the caller's
|
|
466
|
+
// claim — BEFORE the read-back probe below, which can be deferred ~30s
|
|
467
|
+
// by the edit-flood-fuse (cosmetic priority). This is the same predicate
|
|
468
|
+
// as the final `delivered` verdict (all-landed + fresh receipt); the only
|
|
469
|
+
// way `delivered` can subsequently go false is a POSITIVE `absent` probe
|
|
470
|
+
// demoting a chunk, which this orchestrator re-sends in-process (never
|
|
471
|
+
// reopening the nonce to the out-of-band sweep). See onAckClaim.
|
|
472
|
+
if (!ackClaimed && deps.onAckClaim != null) {
|
|
473
|
+
const allLandedNow =
|
|
474
|
+
chunkCount > 0 && ledger.unsentIndices(turnId, chunkCount).length === 0
|
|
475
|
+
if (allLandedNow && backstopReceiptIds(ledger.sentIds(turnId), cardMessageId).length > 0) {
|
|
476
|
+
ackClaimed = true
|
|
477
|
+
try {
|
|
478
|
+
deps.onAckClaim(ledger.sentIds(turnId))
|
|
479
|
+
} catch (err) {
|
|
480
|
+
stderr(
|
|
481
|
+
`telegram gateway: backstop send-ack claim callback threw for turn ${turnId} ` +
|
|
482
|
+
`(non-fatal): ${err instanceof Error ? err.message : String(err)}\n`,
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
440
488
|
// 2. CONFIRM landed-unconfirmed chunks via read-back (#3278). Without a probe
|
|
441
489
|
// wired, landing IS confirmation (pre-#3278) — the fresh-non-card receipt
|
|
442
490
|
// filter below still rejects a card-only "delivery".
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checklist send/update with graceful degradation.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's native `sendChecklist` / `editMessageChecklist` (Bot API 9.1)
|
|
5
|
+
* are **Business-account-only**: both REQUIRE a `business_connection_id`,
|
|
6
|
+
* and the payload nests `title`/`tasks` inside a single `checklist` object
|
|
7
|
+
* (`InputChecklist`) whose tasks each carry a REQUIRED integer `id` and NO
|
|
8
|
+
* completion flag (`InputChecklistTask = { id, text }` — a native checklist
|
|
9
|
+
* cannot be pre-marked done). The original wrapper sent a FLAT
|
|
10
|
+
* `{ chat_id, title, tasks: [{ text, is_completed? }] }` payload, which
|
|
11
|
+
* Telegram rejected with `400: parameter "checklist" is required` — and even
|
|
12
|
+
* a well-formed payload fails for ordinary bot DMs/groups (no business
|
|
13
|
+
* connection), which is every chat this fleet uses.
|
|
14
|
+
*
|
|
15
|
+
* So the tool degrades gracefully instead of surfacing a raw 400:
|
|
16
|
+
* 1. If a business connection id is configured AND the Bot API supports
|
|
17
|
+
* checklists → build the CORRECT native payload and send natively.
|
|
18
|
+
* 2. Otherwise (the normal case) → render the checklist as a formatted
|
|
19
|
+
* text message (bold title + ✅/⬜ task lines — text CAN show the
|
|
20
|
+
* `done` flag) and send via the normal message path. The tool result
|
|
21
|
+
* carries `degraded: "text"` so the caller knows it is not interactive.
|
|
22
|
+
* 3. A failed native attempt falls back to text rather than erroring.
|
|
23
|
+
*
|
|
24
|
+
* Updates work against a per-message in-memory state store (the gateway has
|
|
25
|
+
* no other record of a text-rendered checklist's tasks): a patch is applied
|
|
26
|
+
* to the stored state and the message is edited (natively or as text to
|
|
27
|
+
* match how it was sent). State is process-local — after a gateway restart
|
|
28
|
+
* an update to a pre-restart checklist returns a graceful
|
|
29
|
+
* `unknown_checklist` result unless the patch itself carries a full
|
|
30
|
+
* replacement (title + all task texts), never a raw Telegram error.
|
|
31
|
+
*
|
|
32
|
+
* Pure/deps-injected so the orchestration is unit-testable without a bot
|
|
33
|
+
* (same pattern as checklist-message-handler.ts).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface ChecklistTaskState {
|
|
37
|
+
/** 1-based sequential id assigned at send time (mirrors the native API's
|
|
38
|
+
* required per-task integer id; doubles as the patch handle in text mode). */
|
|
39
|
+
id: number
|
|
40
|
+
text: string
|
|
41
|
+
done: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ChecklistState {
|
|
45
|
+
title: string
|
|
46
|
+
tasks: ChecklistTaskState[]
|
|
47
|
+
/** How the message was actually delivered — updates must match it. */
|
|
48
|
+
mode: 'native' | 'text'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ChecklistPatchTask {
|
|
52
|
+
id?: string | number
|
|
53
|
+
text?: string
|
|
54
|
+
done?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ChecklistPatch {
|
|
58
|
+
title?: string
|
|
59
|
+
tasks?: ChecklistPatchTask[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const CHECKLIST_MAX_TASKS = 30
|
|
63
|
+
|
|
64
|
+
/** Assign sequential 1-based ids and normalize the `done` flag. */
|
|
65
|
+
export function buildChecklistTasks(
|
|
66
|
+
tasks: ReadonlyArray<{ text: string; done?: boolean }>,
|
|
67
|
+
): ChecklistTaskState[] {
|
|
68
|
+
if (tasks.length > CHECKLIST_MAX_TASKS) {
|
|
69
|
+
throw new Error(`checklist exceeds ${CHECKLIST_MAX_TASKS}-task limit (got ${tasks.length})`)
|
|
70
|
+
}
|
|
71
|
+
return tasks.map((t, i) => ({ id: i + 1, text: t.text, done: t.done === true }))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render a checklist as a text message: title header + one ✅/⬜ line per
|
|
76
|
+
* task. Rich (default) output is GFM markdown for `richMessage()`; pass
|
|
77
|
+
* `literal: true` when the access config pins `parseMode: 'text'` (plain
|
|
78
|
+
* sends, no markdown parsing) so the title doesn't ship raw asterisks.
|
|
79
|
+
*/
|
|
80
|
+
export function renderChecklistText(
|
|
81
|
+
cl: { title: string; tasks: ReadonlyArray<{ text: string; done: boolean }> },
|
|
82
|
+
opts?: { literal?: boolean },
|
|
83
|
+
): string {
|
|
84
|
+
const header = opts?.literal ? cl.title : `**${cl.title}**`
|
|
85
|
+
const lines = cl.tasks.map((t) => `${t.done ? '✅' : '⬜'} ${t.text}`)
|
|
86
|
+
return [header, ...lines].join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Apply an update_checklist patch: `title` replaces the title; a task with
|
|
91
|
+
* an `id` matching an existing task updates its text/done in place; a task
|
|
92
|
+
* without an `id` (or with an unknown id) is appended. Returns a NEW state
|
|
93
|
+
* (input untouched). Removal is not supported by the patch shape.
|
|
94
|
+
*/
|
|
95
|
+
export function applyChecklistPatch(
|
|
96
|
+
cl: { title: string; tasks: ReadonlyArray<ChecklistTaskState> },
|
|
97
|
+
patch: ChecklistPatch,
|
|
98
|
+
): { title: string; tasks: ChecklistTaskState[] } {
|
|
99
|
+
const tasks = cl.tasks.map((t) => ({ ...t }))
|
|
100
|
+
for (const p of patch.tasks ?? []) {
|
|
101
|
+
const pid = p.id != null ? Number(p.id) : undefined
|
|
102
|
+
const existing = pid != null ? tasks.find((t) => t.id === pid) : undefined
|
|
103
|
+
if (existing) {
|
|
104
|
+
if (p.text != null) existing.text = p.text
|
|
105
|
+
if (p.done != null) existing.done = p.done
|
|
106
|
+
} else {
|
|
107
|
+
const nextId = pid != null && Number.isFinite(pid)
|
|
108
|
+
? pid
|
|
109
|
+
: tasks.reduce((m, t) => Math.max(m, t.id), 0) + 1
|
|
110
|
+
tasks.push({ id: nextId, text: p.text ?? '', done: p.done === true })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (tasks.length > CHECKLIST_MAX_TASKS) {
|
|
114
|
+
throw new Error(`checklist exceeds ${CHECKLIST_MAX_TASKS}-task limit (got ${tasks.length})`)
|
|
115
|
+
}
|
|
116
|
+
return { title: patch.title ?? cl.title, tasks }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a CORRECT native `sendChecklist` payload per Bot API 9.1:
|
|
121
|
+
* `business_connection_id` is required, `title`/`tasks` nest inside a single
|
|
122
|
+
* `checklist` object (`InputChecklist`), each task carries its integer `id`,
|
|
123
|
+
* and there is NO completion field (`InputChecklistTask` has none).
|
|
124
|
+
*/
|
|
125
|
+
export function buildNativeChecklistPayload(p: {
|
|
126
|
+
businessConnectionId: string
|
|
127
|
+
chatId: number
|
|
128
|
+
title: string
|
|
129
|
+
tasks: ReadonlyArray<ChecklistTaskState>
|
|
130
|
+
replyToMessageId?: number
|
|
131
|
+
protectContent?: boolean
|
|
132
|
+
}): Record<string, unknown> {
|
|
133
|
+
return {
|
|
134
|
+
business_connection_id: p.businessConnectionId,
|
|
135
|
+
chat_id: p.chatId,
|
|
136
|
+
checklist: {
|
|
137
|
+
title: p.title,
|
|
138
|
+
tasks: p.tasks.map((t) => ({ id: t.id, text: t.text })),
|
|
139
|
+
},
|
|
140
|
+
...(p.replyToMessageId != null ? { reply_parameters: { message_id: p.replyToMessageId } } : {}),
|
|
141
|
+
...(p.protectContent === true ? { protect_content: true } : {}),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a native `editMessageChecklist` payload. The native edit REPLACES
|
|
147
|
+
* the whole checklist, so the FULL post-patch state is sent; reusing the
|
|
148
|
+
* stored task ids preserves user tick state for unchanged tasks.
|
|
149
|
+
*/
|
|
150
|
+
export function buildNativeEditChecklistPayload(p: {
|
|
151
|
+
businessConnectionId: string
|
|
152
|
+
chatId: number
|
|
153
|
+
messageId: number
|
|
154
|
+
title: string
|
|
155
|
+
tasks: ReadonlyArray<ChecklistTaskState>
|
|
156
|
+
}): Record<string, unknown> {
|
|
157
|
+
return {
|
|
158
|
+
business_connection_id: p.businessConnectionId,
|
|
159
|
+
chat_id: p.chatId,
|
|
160
|
+
message_id: p.messageId,
|
|
161
|
+
checklist: {
|
|
162
|
+
title: p.title,
|
|
163
|
+
tasks: p.tasks.map((t) => ({ id: t.id, text: t.text })),
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bounded per-message checklist state store (insertion-order eviction). */
|
|
169
|
+
export interface ChecklistStore {
|
|
170
|
+
get(key: string): ChecklistState | undefined
|
|
171
|
+
set(key: string, state: ChecklistState): void
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createChecklistStore(cap = 500): ChecklistStore {
|
|
175
|
+
const map = new Map<string, ChecklistState>()
|
|
176
|
+
return {
|
|
177
|
+
get: (key) => map.get(key),
|
|
178
|
+
set: (key, state) => {
|
|
179
|
+
if (map.has(key)) map.delete(key) // refresh insertion order
|
|
180
|
+
map.set(key, state)
|
|
181
|
+
while (map.size > cap) {
|
|
182
|
+
const oldest = map.keys().next().value as string | undefined
|
|
183
|
+
if (oldest == null) break
|
|
184
|
+
map.delete(oldest)
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function checklistStoreKey(chatId: string, messageId: string | number): string {
|
|
191
|
+
return `${chatId}:${messageId}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Orchestration (deps-injected, unit-testable) ─────────────────────────
|
|
195
|
+
|
|
196
|
+
export interface SendChecklistDeps {
|
|
197
|
+
/** Business connection id, when the operator has one configured. Native
|
|
198
|
+
* checklists are impossible without it (required API parameter). */
|
|
199
|
+
businessConnectionId: string | undefined
|
|
200
|
+
/** Boot-probe result: the connected grammY/Bot API exposes sendChecklist. */
|
|
201
|
+
nativeAvailable: boolean
|
|
202
|
+
sendNative: (payload: Record<string, unknown>) => Promise<{ message_id: number }>
|
|
203
|
+
sendText: (text: string) => Promise<{ message_id: number }>
|
|
204
|
+
/** access.parseMode === 'text' — render without markdown. */
|
|
205
|
+
literalText: boolean
|
|
206
|
+
log: (line: string) => void
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface SendChecklistResult {
|
|
210
|
+
message_id: number
|
|
211
|
+
mode: 'native' | 'text'
|
|
212
|
+
state: ChecklistState
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Send a checklist: natively when possible, degrading to a formatted text
|
|
217
|
+
* message otherwise. NEVER lets a native-send failure escape — the fallback
|
|
218
|
+
* is the text render. (A text-send failure still throws: there is nothing
|
|
219
|
+
* further to degrade to, and the caller's normal error path applies.)
|
|
220
|
+
*/
|
|
221
|
+
export async function performSendChecklist(
|
|
222
|
+
deps: SendChecklistDeps,
|
|
223
|
+
args: {
|
|
224
|
+
title: string
|
|
225
|
+
tasks: ReadonlyArray<{ text: string; done?: boolean }>
|
|
226
|
+
chatId: number
|
|
227
|
+
replyToMessageId?: number
|
|
228
|
+
protectContent?: boolean
|
|
229
|
+
},
|
|
230
|
+
): Promise<SendChecklistResult> {
|
|
231
|
+
const tasks = buildChecklistTasks(args.tasks)
|
|
232
|
+
if (deps.nativeAvailable && deps.businessConnectionId) {
|
|
233
|
+
try {
|
|
234
|
+
const sent = await deps.sendNative(buildNativeChecklistPayload({
|
|
235
|
+
businessConnectionId: deps.businessConnectionId,
|
|
236
|
+
chatId: args.chatId,
|
|
237
|
+
title: args.title,
|
|
238
|
+
tasks,
|
|
239
|
+
replyToMessageId: args.replyToMessageId,
|
|
240
|
+
protectContent: args.protectContent,
|
|
241
|
+
}))
|
|
242
|
+
return {
|
|
243
|
+
message_id: sent.message_id,
|
|
244
|
+
mode: 'native',
|
|
245
|
+
state: { title: args.title, tasks, mode: 'native' },
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
deps.log(`native sendChecklist failed — falling back to text render: ${err}\n`)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const text = renderChecklistText({ title: args.title, tasks }, { literal: deps.literalText })
|
|
252
|
+
const sent = await deps.sendText(text)
|
|
253
|
+
return {
|
|
254
|
+
message_id: sent.message_id,
|
|
255
|
+
mode: 'text',
|
|
256
|
+
state: { title: args.title, tasks, mode: 'text' },
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Tool-result text for send_checklist. The degraded shape carries
|
|
261
|
+
* `degraded: "text"` + a note so the calling agent knows the checklist is a
|
|
262
|
+
* formatted message, not a natively tickable one. */
|
|
263
|
+
export function sendChecklistToolText(r: SendChecklistResult): string {
|
|
264
|
+
return JSON.stringify(
|
|
265
|
+
r.mode === 'native'
|
|
266
|
+
? { ok: true, message_id: r.message_id, mode: 'native' }
|
|
267
|
+
: {
|
|
268
|
+
ok: true,
|
|
269
|
+
message_id: r.message_id,
|
|
270
|
+
mode: 'text',
|
|
271
|
+
degraded: 'text',
|
|
272
|
+
note: 'rendered as a formatted text message (native Telegram checklists require a Business connection) — tasks are not tappable; use update_checklist with task ids 1..N to tick them',
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface UpdateChecklistDeps {
|
|
278
|
+
/** Stored state for this chat:message, if the gateway still has it. */
|
|
279
|
+
state: ChecklistState | undefined
|
|
280
|
+
businessConnectionId: string | undefined
|
|
281
|
+
nativeAvailable: boolean
|
|
282
|
+
editNative: (payload: Record<string, unknown>) => Promise<void>
|
|
283
|
+
editText: (text: string) => Promise<void>
|
|
284
|
+
literalText: boolean
|
|
285
|
+
log: (line: string) => void
|
|
286
|
+
chatId: number
|
|
287
|
+
messageId: number
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type UpdateChecklistResult =
|
|
291
|
+
| { ok: true; mode: 'native' | 'text'; state: ChecklistState }
|
|
292
|
+
| { ok: false; reason: 'unknown_checklist' | 'edit_failed'; hint: string }
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Update a checklist message. Applies the patch to the stored state and
|
|
296
|
+
* re-renders in the mode the message was originally sent in. Failures come
|
|
297
|
+
* back as structured `{ ok: false, reason, hint }` results — never a raw
|
|
298
|
+
* Telegram error thrown at the caller.
|
|
299
|
+
*/
|
|
300
|
+
export async function performUpdateChecklist(
|
|
301
|
+
deps: UpdateChecklistDeps,
|
|
302
|
+
patch: ChecklistPatch,
|
|
303
|
+
): Promise<UpdateChecklistResult> {
|
|
304
|
+
let base = deps.state
|
|
305
|
+
if (!base) {
|
|
306
|
+
// State lost (gateway restart / evicted). If the patch is a full
|
|
307
|
+
// replacement — a title plus tasks that all carry text — we can rebuild
|
|
308
|
+
// and edit as text (the fleet-normal mode). Otherwise we cannot know
|
|
309
|
+
// the existing tasks; degrade gracefully.
|
|
310
|
+
const fullReplacement =
|
|
311
|
+
patch.title != null &&
|
|
312
|
+
(patch.tasks?.length ?? 0) > 0 &&
|
|
313
|
+
(patch.tasks ?? []).every((t) => t.text != null)
|
|
314
|
+
if (!fullReplacement) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
reason: 'unknown_checklist',
|
|
318
|
+
hint: 'no stored state for this checklist (gateway restarted?). Re-send it with send_checklist, or pass a full replacement (title + every task\'s text) to update_checklist.',
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
base = { title: patch.title!, tasks: [], mode: 'text' }
|
|
322
|
+
}
|
|
323
|
+
let next: { title: string; tasks: ChecklistTaskState[] }
|
|
324
|
+
try {
|
|
325
|
+
next = applyChecklistPatch(base, patch)
|
|
326
|
+
} catch (err) {
|
|
327
|
+
return { ok: false, reason: 'edit_failed', hint: String(err) }
|
|
328
|
+
}
|
|
329
|
+
const state: ChecklistState = { title: next.title, tasks: next.tasks, mode: base.mode }
|
|
330
|
+
if (base.mode === 'native') {
|
|
331
|
+
// A native checklist message cannot be edited into plain text — there is
|
|
332
|
+
// no text fallback here; a failure surfaces as a structured result.
|
|
333
|
+
if (!deps.nativeAvailable || !deps.businessConnectionId) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
reason: 'edit_failed',
|
|
337
|
+
hint: 'this checklist was sent natively but the business connection / checklist API is no longer available.',
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
await deps.editNative(buildNativeEditChecklistPayload({
|
|
342
|
+
businessConnectionId: deps.businessConnectionId,
|
|
343
|
+
chatId: deps.chatId,
|
|
344
|
+
messageId: deps.messageId,
|
|
345
|
+
title: state.title,
|
|
346
|
+
tasks: state.tasks,
|
|
347
|
+
}))
|
|
348
|
+
return { ok: true, mode: 'native', state }
|
|
349
|
+
} catch (err) {
|
|
350
|
+
deps.log(`native editMessageChecklist failed: ${err}\n`)
|
|
351
|
+
return { ok: false, reason: 'edit_failed', hint: String(err) }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await deps.editText(renderChecklistText(state, { literal: deps.literalText }))
|
|
356
|
+
return { ok: true, mode: 'text', state }
|
|
357
|
+
} catch (err) {
|
|
358
|
+
deps.log(`checklist text edit failed: ${err}\n`)
|
|
359
|
+
return { ok: false, reason: 'edit_failed', hint: String(err) }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Tool-result text for update_checklist — structured for both outcomes. */
|
|
364
|
+
export function updateChecklistToolText(r: UpdateChecklistResult, messageId: number): string {
|
|
365
|
+
return JSON.stringify(
|
|
366
|
+
r.ok
|
|
367
|
+
? { ok: true, message_id: messageId, mode: r.mode, ...(r.mode === 'text' ? { degraded: 'text' } : {}) }
|
|
368
|
+
: { ok: false, reason: r.reason, hint: r.hint },
|
|
369
|
+
)
|
|
370
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction-in-flight marker (#4058 — spurious 300s silence-fallback fire
|
|
3
|
+
* during mid-turn auto-compaction).
|
|
4
|
+
*
|
|
5
|
+
* The blind spot this closes: when Claude Code hits mid-turn auto-compaction
|
|
6
|
+
* (the session ran out of context and spends minutes summarizing), the model
|
|
7
|
+
* emits ZERO output and runs ZERO tools — the gateway sees pure silence, so
|
|
8
|
+
* the silence-poke 300s framework fallback fired on a perfectly healthy turn:
|
|
9
|
+
* a spurious "⚠️ no output for 5 min — the framework ended that stalled turn"
|
|
10
|
+
* card plus an obligation re-present, right before the turn completed
|
|
11
|
+
* normally. Observed live: ~1m50s of tools then ~3m25s compacting = 304s of
|
|
12
|
+
* "silence" → false fire.
|
|
13
|
+
*
|
|
14
|
+
* The signal problem: the transcript JSONL carries a compaction record ONLY
|
|
15
|
+
* at the END (`{"type":"system","subtype":"compact_boundary",...}` — it
|
|
16
|
+
* embeds the compaction's own durationMs, so it cannot exist before the
|
|
17
|
+
* compaction finishes). Nothing observable lands at the START from the
|
|
18
|
+
* stream. The START therefore comes from Claude Code's PreCompact hook
|
|
19
|
+
* (`hooks/compaction-marker-precompact.mjs`), which writes
|
|
20
|
+
* `<STATE_DIR>/compaction-in-flight.json` the moment compaction begins —
|
|
21
|
+
* the same sidecar-file pattern as `turn-active-marker.ts` and the
|
|
22
|
+
* tool-label sidecar (#783).
|
|
23
|
+
*
|
|
24
|
+
* Consumers:
|
|
25
|
+
* - `liveness-wiring.ts` wires `isCompactionInFlight` into the silence-poke
|
|
26
|
+
* deps: marker present AND younger than the fallback hard ceiling ⇒ the
|
|
27
|
+
* 300s fallback is DEFERRED via the exact same `underCeiling` mechanism
|
|
28
|
+
* as the #1292/#3519 in-flight-tool defers — a genuinely-wedged
|
|
29
|
+
* compaction still unwedges once silence crosses `fallbackHardCeiling`.
|
|
30
|
+
* - `silence-poke-session-event.ts` removes the marker on the
|
|
31
|
+
* `compact_boundary` session event (the deterministic END) and counts
|
|
32
|
+
* the boundary as production, so the resumed turn gets a fresh 300s
|
|
33
|
+
* window instead of firing on the very next tick.
|
|
34
|
+
*
|
|
35
|
+
* Staleness is double-bounded: the reader treats an old marker as absent
|
|
36
|
+
* (age bound supplied by the caller — the hard ceiling), so a marker leaked
|
|
37
|
+
* by a crash between compaction start and boundary can never defer a future
|
|
38
|
+
* genuine wedge for more than one ceiling window.
|
|
39
|
+
*
|
|
40
|
+
* Pure file I/O; clock injectable for tests; never throws.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { statSync, unlinkSync } from 'node:fs'
|
|
44
|
+
import { homedir } from 'node:os'
|
|
45
|
+
import { join } from 'node:path'
|
|
46
|
+
|
|
47
|
+
export const COMPACTION_MARKER_FILE = 'compaction-in-flight.json'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The gateway's state dir — same resolution as gateway.ts (`STATE_DIR`).
|
|
51
|
+
* Exported so `silence-poke-session-event.ts` (which is handed no stateDir)
|
|
52
|
+
* can clear the marker without widening its call signature.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveTelegramStateDir(): string {
|
|
55
|
+
return process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Age (ms) of the compaction marker's mtime, or null when absent/unstattable.
|
|
60
|
+
* The PreCompact hook (re)writes the file at compaction start, so a small age
|
|
61
|
+
* means a compaction began recently and may still be running. Never throws.
|
|
62
|
+
*/
|
|
63
|
+
export function readCompactionMarkerAgeMs(stateDir: string, now?: number): number | null {
|
|
64
|
+
try {
|
|
65
|
+
const st = statSync(join(stateDir, COMPACTION_MARKER_FILE))
|
|
66
|
+
return (now ?? Date.now()) - st.mtimeMs
|
|
67
|
+
} catch {
|
|
68
|
+
return null // ENOENT / unstattable → no compaction in flight
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove the marker — called on the `compact_boundary` session event (the
|
|
74
|
+
* compaction's deterministic END record in the transcript). Idempotent;
|
|
75
|
+
* ENOENT and any other unlink failure are swallowed (the age bound in the
|
|
76
|
+
* reader is the correctness backstop, removal is the fast path).
|
|
77
|
+
*/
|
|
78
|
+
export function removeCompactionMarker(stateDir: string): void {
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(join(stateDir, COMPACTION_MARKER_FILE))
|
|
81
|
+
} catch {
|
|
82
|
+
// best-effort — staleness bound in readCompactionMarkerAgeMs self-heals
|
|
83
|
+
}
|
|
84
|
+
}
|