switchroom 0.18.3 → 0.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- 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/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/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/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
|
@@ -33,6 +33,28 @@
|
|
|
33
33
|
* delivery obligation.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
// Verified complete (2026-07-09, adversarial-review follow-up): `reply`
|
|
37
|
+
// and `stream_reply` are the ONLY two MCP tools whose payload is the
|
|
38
|
+
// model's free-text final-answer content reaching the user — the exact
|
|
39
|
+
// scope `final-answer-detect.ts`'s own docstring claims ("plain assistant
|
|
40
|
+
// transcript text instead of a `reply` / `stream_reply` tool call").
|
|
41
|
+
// Cross-checked the full tool surface in `telegram-plugin/bridge/bridge.ts`
|
|
42
|
+
// (`TOOL_SCHEMAS`, kept in sync with `gateway/gateway.ts`): `edit_message`
|
|
43
|
+
// explicitly does NOT ping/deliver a fresh answer (its own description says
|
|
44
|
+
// "send a new reply when a long task completes"); `react`, `pin_message`,
|
|
45
|
+
// `delete_message`, `forward_message`, `send_typing`, `download_attachment`,
|
|
46
|
+
// `get_recent_messages` carry no model-authored answer text at all;
|
|
47
|
+
// `send_checklist` / `send_sticker` / `send_gif` / `ask_user` /
|
|
48
|
+
// `update_checklist` deliver structured/templated content, not the turn's
|
|
49
|
+
// prose answer, and are intentionally a different interaction pattern (a
|
|
50
|
+
// question or a fixed artifact, not "the answer"). `stream_reply` sends its
|
|
51
|
+
// FULL cumulative text snapshot on every call (not incremental chunks —
|
|
52
|
+
// see `stream-reply-handler.ts` docstring), and each call is its own
|
|
53
|
+
// `tool_use` block in the transcript in chronological order, so the
|
|
54
|
+
// "last delivery event wins" walk below already treats a stream's final
|
|
55
|
+
// (`done:true`) call as the qualifying one regardless of how many
|
|
56
|
+
// intermediate non-final `stream_reply` calls preceded it. No gap found;
|
|
57
|
+
// re-verify only if a new outbound-delivery tool is added to bridge.ts.
|
|
36
58
|
const REPLY_TOOLS = new Set([
|
|
37
59
|
'mcp__switchroom-telegram__reply',
|
|
38
60
|
'mcp__switchroom-telegram__stream_reply',
|
|
@@ -126,24 +148,47 @@ function buildTurnKey(chatId, threadId) {
|
|
|
126
148
|
return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
|
|
127
149
|
}
|
|
128
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Build the `{ decided: 'block', ... }` result shape, populating
|
|
153
|
+
* `turnKey`/`chatId`/`threadId` from the enqueue envelope when
|
|
154
|
+
* available. Shared by both block branches below.
|
|
155
|
+
*
|
|
156
|
+
* @param {ReturnType<typeof parseChannelEnvelope>} envelope
|
|
157
|
+
* @param {string} reason
|
|
158
|
+
*/
|
|
159
|
+
function buildBlockResult(envelope, reason) {
|
|
160
|
+
const block = { decided: 'block', reason }
|
|
161
|
+
if (envelope.chatId) {
|
|
162
|
+
block.chatId = envelope.chatId
|
|
163
|
+
block.threadId = envelope.threadId
|
|
164
|
+
block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
|
|
165
|
+
}
|
|
166
|
+
return block
|
|
167
|
+
}
|
|
168
|
+
|
|
129
169
|
/**
|
|
130
170
|
* Scan a JSONL transcript and decide whether the current turn ended
|
|
131
171
|
* with a final reply delivered.
|
|
132
172
|
*
|
|
133
173
|
* Returns:
|
|
134
|
-
* { decided: 'allow', reason } — qualifying reply OR silent marker found
|
|
174
|
+
* { decided: 'allow', reason } — qualifying reply OR silent marker found,
|
|
175
|
+
* and nothing undelivered was written
|
|
176
|
+
* after it
|
|
135
177
|
* { decided: 'block', reason, turnKey?, chatId?, threadId? }
|
|
136
|
-
* — turn-start found, no qualifying reply
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
178
|
+
* — turn-start found, no qualifying reply
|
|
179
|
+
* delivered (or a qualifying reply
|
|
180
|
+
* happened, but the model kept writing
|
|
181
|
+
* plain-text content afterward that was
|
|
182
|
+
* never sent through a delivery tool).
|
|
183
|
+
* `turnKey`/`chatId`/`threadId` populated
|
|
184
|
+
* from the enqueue's channel envelope so
|
|
185
|
+
* the hook can write a state file shape
|
|
186
|
+
* that matches what the gateway's
|
|
187
|
+
* `recordSilentTurnEnd` would write —
|
|
188
|
+
* keeping the retry-count preservation
|
|
189
|
+
* gate at `silent-end.ts:114` happy when
|
|
190
|
+
* the gateway's later write reads back
|
|
191
|
+
* the hook's state.
|
|
147
192
|
* { decided: 'unknown', reason } — couldn't locate turn-start; caller fail-open
|
|
148
193
|
*
|
|
149
194
|
* Turn-start anchor: the most recent `queue-operation`/`enqueue` line
|
|
@@ -154,6 +199,38 @@ function buildTurnKey(chatId, threadId) {
|
|
|
154
199
|
* edge case where the model replied combined ahead of the second
|
|
155
200
|
* enqueue's append; accepted residual.)
|
|
156
201
|
*
|
|
202
|
+
* IMPORTANT — trailing-content check (fixes the "at least once" bug):
|
|
203
|
+
* a naive scan that returns 'allow' on the FIRST qualifying reply it
|
|
204
|
+
* finds is wrong. A turn can legitimately call `reply` early (e.g. a
|
|
205
|
+
* notification-bearing interim ack — `disable_notification` unset/false
|
|
206
|
+
* always qualifies as "final" under `isFinalAnswerReply`, regardless of
|
|
207
|
+
* how short the text is) and then keep working, eventually writing a
|
|
208
|
+
* SUBSTANTIVE plain-text verdict that never goes through `reply` again.
|
|
209
|
+
* The early ack satisfied "reply was called somewhere in this turn",
|
|
210
|
+
* but the user never saw the actual answer. So this scan does NOT
|
|
211
|
+
* short-circuit on the first match: it walks the ENTIRE turn in
|
|
212
|
+
* chronological order, remembers the position of the LAST qualifying
|
|
213
|
+
* delivery event (a final-answer reply/stream_reply call, or an
|
|
214
|
+
* explicit silent-marker), and then checks whether any plain assistant
|
|
215
|
+
* text block appears AFTER that position. If one does, that content was
|
|
216
|
+
* written but never delivered — block, same as the zero-reply case.
|
|
217
|
+
*
|
|
218
|
+
* This deliberately does NOT flag a turn that ends on a delivery
|
|
219
|
+
* tool_use with nothing after it (the normal, healthy shape), nor a
|
|
220
|
+
* turn where all assistant text precedes the final delivering reply
|
|
221
|
+
* (the model narrating before it sends) — only text that comes AFTER
|
|
222
|
+
* the last delivery event counts as a drop.
|
|
223
|
+
*
|
|
224
|
+
* Substance floor (#2956 review): the trailing-text check only BLOCKS
|
|
225
|
+
* when the trailing text is SUBSTANTIVE — at least FINAL_ANSWER_MIN_CHARS
|
|
226
|
+
* (the same bar `isFinalAnswerReply` uses to recognise a real answer). A
|
|
227
|
+
* SHORT trailing pleasantry / closer after a delivered reply ("Let me
|
|
228
|
+
* know if you need anything else.") is not a dropped answer and must not
|
|
229
|
+
* trigger a re-prompt (no-spam / single-answer invariant). A long
|
|
230
|
+
* trailing verdict the model forgot to send still blocks. The floor keeps
|
|
231
|
+
* the "at least once" guarantee for real dropped answers while stopping a
|
|
232
|
+
* false-positive that burned retry budget on healthy turns.
|
|
233
|
+
*
|
|
157
234
|
* @param {string} jsonl
|
|
158
235
|
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, chatId?: string, threadId?: number | null }}
|
|
159
236
|
*/
|
|
@@ -178,8 +255,13 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
178
255
|
return { decided: 'unknown', reason: 'no-turn-start' }
|
|
179
256
|
}
|
|
180
257
|
|
|
181
|
-
// 2.
|
|
182
|
-
//
|
|
258
|
+
// 2. Flatten every assistant content block (text and tool_use) from
|
|
259
|
+
// the turn into a single chronologically-ordered list. Classify
|
|
260
|
+
// each block as it's collected: a "delivery" event (qualifying
|
|
261
|
+
// final-answer reply/stream_reply, or an explicit silent marker —
|
|
262
|
+
// whether emitted as plain text or as a reply-tool payload) or
|
|
263
|
+
// plain undelivered text.
|
|
264
|
+
const blocks = []
|
|
183
265
|
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
184
266
|
const line = lines[i]
|
|
185
267
|
if (!line || line[0] !== '{') continue
|
|
@@ -193,16 +275,28 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
193
275
|
const content = obj?.message?.content
|
|
194
276
|
if (!Array.isArray(content)) continue
|
|
195
277
|
for (const c of content) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
278
|
+
if (c?.type === 'text') {
|
|
279
|
+
// Plain assistant text carve-out (#2053): a turn that ends with
|
|
280
|
+
// a trailing bare NO_REPLY / HEARTBEAT_OK line — emitted as
|
|
281
|
+
// plain transcript text, NOT through the reply tool — is the
|
|
282
|
+
// model explicitly signalling "intentionally silent". Treat a
|
|
283
|
+
// trailing-marker text block as a delivery/silence event;
|
|
284
|
+
// anything else is candidate undelivered content.
|
|
285
|
+
if (endsWithSilentMarker(String(c.text ?? ''))) {
|
|
286
|
+
blocks.push({ kind: 'deliver', reason: 'silent-marker-text' })
|
|
287
|
+
} else if (String(c.text ?? '').trim().length > 0) {
|
|
288
|
+
// Carry the trimmed char count so the trailing-content check
|
|
289
|
+
// (step 3) can apply a substance floor: a SHORT trailing text
|
|
290
|
+
// after a delivered reply (a pleasantry / closer like "Let me
|
|
291
|
+
// know if you need anything else.") is NOT a dropped answer and
|
|
292
|
+
// must not trigger a re-prompt (no-spam invariant). Only
|
|
293
|
+
// SUBSTANTIVE trailing text — at least FINAL_ANSWER_MIN_CHARS,
|
|
294
|
+
// the same bar `isFinalAnswerReply` uses to recognise a real
|
|
295
|
+
// answer — counts as "undelivered content the user was waiting
|
|
296
|
+
// on". #2956 review finding.
|
|
297
|
+
blocks.push({ kind: 'text', chars: String(c.text ?? '').trim().length })
|
|
298
|
+
}
|
|
299
|
+
continue
|
|
206
300
|
}
|
|
207
301
|
if (c?.type !== 'tool_use') continue
|
|
208
302
|
if (!REPLY_TOOLS.has(c.name)) continue
|
|
@@ -214,33 +308,63 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
214
308
|
// prose+trailing-marker shape (#2053). Same posture as the
|
|
215
309
|
// gateway's silent-marker suppression at gateway.ts:6692.
|
|
216
310
|
if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
|
|
217
|
-
|
|
311
|
+
blocks.push({ kind: 'deliver', reason: 'silent-marker' })
|
|
312
|
+
continue
|
|
218
313
|
}
|
|
219
314
|
if (isFinalAnswerReply({
|
|
220
315
|
text,
|
|
221
316
|
disableNotification: input.disable_notification === true,
|
|
222
317
|
done: input.done === true,
|
|
223
318
|
})) {
|
|
224
|
-
|
|
319
|
+
blocks.push({ kind: 'deliver', reason: 'final-reply' })
|
|
320
|
+
continue
|
|
225
321
|
}
|
|
322
|
+
// Non-qualifying reply call (interim ack) — delivered to the
|
|
323
|
+
// user, but not a "final answer". It's neither a delivery event
|
|
324
|
+
// nor undelivered text, so it doesn't affect the decision either
|
|
325
|
+
// way; simply not pushed.
|
|
226
326
|
}
|
|
227
327
|
}
|
|
228
328
|
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
329
|
+
// 3. Find the LAST delivery event's position, then check whether any
|
|
330
|
+
// plain-text block appears strictly after it. This is the fix for
|
|
331
|
+
// the "at least once" bug: a naive scan that stops at the FIRST
|
|
332
|
+
// qualifying reply misses substantive content the model wrote
|
|
333
|
+
// afterward and never (re-)sent.
|
|
334
|
+
let lastAllowBlockIdx = -1
|
|
335
|
+
let lastAllowReason = null
|
|
336
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
337
|
+
if (blocks[i].kind === 'deliver') {
|
|
338
|
+
lastAllowBlockIdx = i
|
|
339
|
+
lastAllowReason = blocks[i].reason
|
|
340
|
+
}
|
|
237
341
|
}
|
|
342
|
+
const sawUndeliveredTextAfterAllow = blocks
|
|
343
|
+
.slice(lastAllowBlockIdx + 1)
|
|
344
|
+
.some((b) => b.kind === 'text' && (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS)
|
|
238
345
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
346
|
+
if (lastAllowBlockIdx === -1) {
|
|
347
|
+
// No qualifying delivery/silence event anywhere in the turn.
|
|
348
|
+
// Cron-fired turns (#2053): a scheduled turn that produced no
|
|
349
|
+
// qualifying reply is NOT a delivery failure the user is waiting
|
|
350
|
+
// on — nagging it only pushes the model to escape the loop by
|
|
351
|
+
// shoving a NO_REPLY sentinel through the reply tool, which leaks
|
|
352
|
+
// to chat. A cron turn that genuinely needs to speak will have
|
|
353
|
+
// called reply (caught above); otherwise let it end silently.
|
|
354
|
+
if (envelope.source === 'cron') {
|
|
355
|
+
return { decided: 'allow', reason: 'cron-source' }
|
|
356
|
+
}
|
|
357
|
+
return buildBlockResult(envelope, 'no-final-reply')
|
|
244
358
|
}
|
|
245
|
-
|
|
359
|
+
|
|
360
|
+
if (sawUndeliveredTextAfterAllow) {
|
|
361
|
+
// A qualifying delivery DID happen somewhere in the turn, but the
|
|
362
|
+
// model kept writing after it and that trailing content was never
|
|
363
|
+
// sent through a delivery tool. This is the "at least once" bug:
|
|
364
|
+
// an early ack (or any qualifying reply) must not amnesty
|
|
365
|
+
// everything written afterward.
|
|
366
|
+
return buildBlockResult(envelope, 'trailing-text-after-reply')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { decided: 'allow', reason: lastAllowReason }
|
|
246
370
|
}
|
|
@@ -55,7 +55,8 @@ const SCHEMA_SQL = `
|
|
|
55
55
|
status TEXT NOT NULL,
|
|
56
56
|
result_summary TEXT,
|
|
57
57
|
jsonl_agent_id TEXT,
|
|
58
|
-
parent_agent_id TEXT
|
|
58
|
+
parent_agent_id TEXT,
|
|
59
|
+
model TEXT
|
|
59
60
|
);
|
|
60
61
|
CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
|
|
61
62
|
CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
|
|
@@ -157,14 +158,14 @@ function spawnSql(dbPath, sql, cb) {
|
|
|
157
158
|
// DB write
|
|
158
159
|
// ---------------------------------------------------------------------------
|
|
159
160
|
|
|
160
|
-
function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, now }, done) {
|
|
161
|
+
function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, model, now }, done) {
|
|
161
162
|
const INSERT_SQL = `
|
|
162
163
|
INSERT OR IGNORE INTO subagents
|
|
163
164
|
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
164
|
-
background, started_at, last_activity_at, status)
|
|
165
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running')
|
|
165
|
+
background, started_at, last_activity_at, status, model)
|
|
166
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
|
|
166
167
|
`
|
|
167
|
-
const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now]
|
|
168
|
+
const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now, model ?? null]
|
|
168
169
|
|
|
169
170
|
// Resolve a synchronous SQLite binding. Try in order:
|
|
170
171
|
// 1. node:sqlite (Node 22+, production path) — exposes DatabaseSync
|
|
@@ -207,6 +208,13 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
207
208
|
if (hasParentAgentCol == null) {
|
|
208
209
|
db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
|
|
209
210
|
}
|
|
211
|
+
// Migrate older DBs that pre-date the live-model column.
|
|
212
|
+
const hasModelCol = db
|
|
213
|
+
.prepare("SELECT name FROM pragma_table_info('subagents') WHERE name = 'model'")
|
|
214
|
+
.get()
|
|
215
|
+
if (hasModelCol == null) {
|
|
216
|
+
db.exec('ALTER TABLE subagents ADD COLUMN model TEXT')
|
|
217
|
+
}
|
|
210
218
|
// Verify the marker-derived parent_turn_key (snapParams[2]) actually has
|
|
211
219
|
// a row in the turns table before trusting it. The gateway writes the
|
|
212
220
|
// turn-active marker even when recordTurnStart's INSERT failed (the two
|
|
@@ -235,7 +243,7 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
235
243
|
return
|
|
236
244
|
}
|
|
237
245
|
|
|
238
|
-
// sqlite3 CLI fallback —
|
|
246
|
+
// sqlite3 CLI fallback — non-blocking spawns sequenced via callbacks.
|
|
239
247
|
// This legacy path (neither node:sqlite nor bun:sqlite available) can't
|
|
240
248
|
// cheaply verify the marker's turn_key against the turns table, so drop
|
|
241
249
|
// parent_turn_key and let the gateway's window backfill attribute it.
|
|
@@ -244,7 +252,15 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
|
|
|
244
252
|
params[2] = null
|
|
245
253
|
spawnSql(dbPath, SCHEMA_SQL.replace(/\n\s+/g, ' '), (err) => {
|
|
246
254
|
if (err) { done(err); return }
|
|
247
|
-
|
|
255
|
+
// Best-effort model-column migration for a legacy DB: the INSERT below
|
|
256
|
+
// references the model column, which a pre-model table lacks (CREATE TABLE
|
|
257
|
+
// IF NOT EXISTS is a no-op there, so the schema exec doesn't add it). The
|
|
258
|
+
// ALTER fails with "duplicate column name" when the column already exists —
|
|
259
|
+
// that error is EXPECTED and deliberately ignored; on any failure we still
|
|
260
|
+
// proceed to the INSERT, which surfaces a real problem via `done`.
|
|
261
|
+
spawnSql(dbPath, 'ALTER TABLE subagents ADD COLUMN model TEXT', () => {
|
|
262
|
+
spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
|
|
263
|
+
})
|
|
248
264
|
})
|
|
249
265
|
}
|
|
250
266
|
|
|
@@ -363,6 +379,13 @@ function main() {
|
|
|
363
379
|
agentType: input.subagent_type ?? null,
|
|
364
380
|
description: input.description ?? null,
|
|
365
381
|
background: input.run_in_background === true ? 1 : 0,
|
|
382
|
+
// First-paint model for the worker card: the Agent tool payload carries
|
|
383
|
+
// the model the sub-agent will run under (`tool_input.model`), available
|
|
384
|
+
// BEFORE the sub-agent writes its first assistant line. Persisted so the
|
|
385
|
+
// card can render the model from dispatch; the watcher later overwrites it
|
|
386
|
+
// from the worker's own transcript (transcript wins). Only a non-empty
|
|
387
|
+
// string is stored — never guess from config.
|
|
388
|
+
model: typeof input.model === 'string' && input.model.length > 0 ? input.model : null,
|
|
366
389
|
now: Date.now(),
|
|
367
390
|
},
|
|
368
391
|
(err) => {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared friendly-model formatter for progress / activity surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Every progress card renders the model actually in use, sourced LIVE from the
|
|
5
|
+
* session transcripts (`message.model` on each `type:"assistant"` line — the
|
|
6
|
+
* exact resolved model for that API call). This module owns the two pure
|
|
7
|
+
* decisions both the capture side (session-tail projection) and the render side
|
|
8
|
+
* (activity/worker cards) depend on:
|
|
9
|
+
*
|
|
10
|
+
* - `isModelSentinel(v)` — is this a value we must NOT treat as a real model?
|
|
11
|
+
* Compaction / synthetic transcript lines carry `model:"<synthetic>"`, and
|
|
12
|
+
* test fixtures may carry junk. Anything starting with `<`, empty, or not
|
|
13
|
+
* shaped like a model id is a sentinel: the capture side SKIPS it and keeps
|
|
14
|
+
* the previous value; it is never rendered.
|
|
15
|
+
* - `formatModelLabel(v)` — the short friendly form shown on a card's metrics
|
|
16
|
+
* line (e.g. `claude-opus-4-8` → `opus 4.8`). Returns null for sentinels so
|
|
17
|
+
* the caller simply omits the model field (never guesses from config).
|
|
18
|
+
*
|
|
19
|
+
* Friendly-form rules (verified against live fleet transcript values):
|
|
20
|
+
* - `claude-opus-4-8` → `opus 4.8`
|
|
21
|
+
* - `claude-sonnet-5` → `sonnet 5`
|
|
22
|
+
* - `claude-haiku-4-5-20251001` → `haiku 4.5` (trailing YYYYMMDD dropped)
|
|
23
|
+
* - `sr-glm-5`, `sr-gpt-5.5` → verbatim (LiteLLM routing ids stay literal)
|
|
24
|
+
* - anything else → verbatim (unknown but model-shaped)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Trailing 8-digit date stamp (YYYYMMDD) on a claude model id — dropped. */
|
|
28
|
+
const DATE_SUFFIX = /^\d{8}$/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True when `model` must NOT be treated as a real resolved model. Compaction /
|
|
32
|
+
* synthetic lines write `model:"<synthetic>"`; fixtures may carry junk. Callers
|
|
33
|
+
* that see a sentinel keep the previously-seen model (or omit the field).
|
|
34
|
+
*/
|
|
35
|
+
export function isModelSentinel(model: unknown): boolean {
|
|
36
|
+
if (typeof model !== 'string') return true
|
|
37
|
+
const m = model.trim()
|
|
38
|
+
if (m.length === 0) return true
|
|
39
|
+
// Synthetic / placeholder sentinels: `<synthetic>`, `<compact>`, etc.
|
|
40
|
+
if (m.startsWith('<')) return true
|
|
41
|
+
// Model ids are simple tokens (letters, digits, dot, dash, underscore,
|
|
42
|
+
// slash). Anything with whitespace or leading punctuation is junk.
|
|
43
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(m)) return true
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Short friendly label for a resolved model id, or null when `model` is absent
|
|
49
|
+
* or a sentinel (caller omits the field — never guesses). See module header for
|
|
50
|
+
* the rules.
|
|
51
|
+
*/
|
|
52
|
+
export function formatModelLabel(model: string | null | undefined): string | null {
|
|
53
|
+
if (model == null) return null
|
|
54
|
+
const m = model.trim()
|
|
55
|
+
if (isModelSentinel(m)) return null
|
|
56
|
+
// LiteLLM / self-routed ids stay verbatim — the sr- prefix is meaningful.
|
|
57
|
+
if (m.startsWith('sr-')) return m
|
|
58
|
+
if (m.startsWith('claude-')) {
|
|
59
|
+
const rest = m.slice('claude-'.length)
|
|
60
|
+
const parts = rest.split('-').filter((p) => p.length > 0)
|
|
61
|
+
if (parts.length === 0) return m
|
|
62
|
+
const family = parts[0]
|
|
63
|
+
const version = parts.slice(1).filter((p) => !DATE_SUFFIX.test(p))
|
|
64
|
+
if (version.length === 0) return family
|
|
65
|
+
return `${family} ${version.join('.')}`
|
|
66
|
+
}
|
|
67
|
+
// Unknown but model-shaped (a future family, a bare alias) — show verbatim.
|
|
68
|
+
return m
|
|
69
|
+
}
|
|
@@ -26,6 +26,8 @@ export type OperatorEventKind =
|
|
|
26
26
|
| 'agent-restarted-unexpectedly'
|
|
27
27
|
| 'unknown-4xx'
|
|
28
28
|
| 'unknown-5xx'
|
|
29
|
+
| 'config-warning'
|
|
30
|
+
| 'always-allow-persist-failed'
|
|
29
31
|
|
|
30
32
|
export interface OperatorEvent {
|
|
31
33
|
kind: OperatorEventKind
|
|
@@ -375,6 +377,49 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
375
377
|
],
|
|
376
378
|
},
|
|
377
379
|
}
|
|
380
|
+
|
|
381
|
+
// Deliberately low-severity framing (ℹ️, Dismiss-only — no Restart /
|
|
382
|
+
// Reauth / Show-logs actions): config-warning is for boot-time config
|
|
383
|
+
// problems (e.g. a dropped `person_id` entry) that must be visible to
|
|
384
|
+
// the operator but MUST NOT read like a real outage or page anyone.
|
|
385
|
+
case 'config-warning':
|
|
386
|
+
return {
|
|
387
|
+
text: [
|
|
388
|
+
`ℹ️ **Config warning** for **${agent}**.`,
|
|
389
|
+
detail ? `_${detail}_` : '',
|
|
390
|
+
`Non-urgent — config will keep working with today's fallback behavior.`,
|
|
391
|
+
]
|
|
392
|
+
.filter(Boolean)
|
|
393
|
+
.join('\n'),
|
|
394
|
+
keyboard: {
|
|
395
|
+
inline_keyboard: [
|
|
396
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// #2973 pt.3 — a durable "Always allow" persist exhausted its retry
|
|
402
|
+
// budget (always-allow-persist-queue.ts) or hit a non-retryable error
|
|
403
|
+
// (e.g. E_CONFIG_EDIT_DISABLED). MUST be a NEW message, not a card
|
|
404
|
+
// edit — the original permission card was already edited to the
|
|
405
|
+
// interim "saving durably in background…" state and card edits don't
|
|
406
|
+
// ping the operator, so a silent edit here would leave the failure
|
|
407
|
+
// unnoticed indefinitely.
|
|
408
|
+
case 'always-allow-persist-failed':
|
|
409
|
+
return {
|
|
410
|
+
text: [
|
|
411
|
+
`⚠️ Your "Always allow" for **${agent}** didn't stick.`,
|
|
412
|
+
detail ? `_${detail}_` : '',
|
|
413
|
+
`It will ask again.`,
|
|
414
|
+
]
|
|
415
|
+
.filter(Boolean)
|
|
416
|
+
.join('\n'),
|
|
417
|
+
keyboard: {
|
|
418
|
+
inline_keyboard: [
|
|
419
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
420
|
+
],
|
|
421
|
+
},
|
|
422
|
+
}
|
|
378
423
|
}
|
|
379
424
|
}
|
|
380
425
|
|
|
@@ -419,9 +419,15 @@ function tick(now: number): void {
|
|
|
419
419
|
newText,
|
|
420
420
|
literalText: s.anchorLiteralText,
|
|
421
421
|
}
|
|
422
|
-
// Fire-and-forget so a slow edit doesn't block the tick loop.
|
|
423
|
-
//
|
|
424
|
-
// /
|
|
422
|
+
// Fire-and-forget so a slow edit doesn't block the tick loop. The
|
|
423
|
+
// production `editMessage` dep is `swallowingApiCall`-wrapped, which
|
|
424
|
+
// catches every transport outcome (not-modified / not-found / 429 /
|
|
425
|
+
// network) upstream and never rejects — so in production this `.catch`
|
|
426
|
+
// is a backstop that rarely fires. It is kept as a contract-level guard
|
|
427
|
+
// (a throwing dep, or a future non-swallowing wiring, must not log a
|
|
428
|
+
// scary "edit failed" warning for a best-effort liveness surface nor
|
|
429
|
+
// keep hammering a dead anchor). Only a genuinely unexpected error
|
|
430
|
+
// reaches the fallthrough; transport classes are silent.
|
|
425
431
|
void Promise.resolve()
|
|
426
432
|
.then(() => activeDeps!.editMessage(editCtx))
|
|
427
433
|
.then(() => {
|
|
@@ -432,10 +438,39 @@ function tick(now: number): void {
|
|
|
432
438
|
})
|
|
433
439
|
})
|
|
434
440
|
.catch((err) => {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
441
|
+
const desc =
|
|
442
|
+
err instanceof Error ? err.message : err != null && typeof err === 'object' && 'description' in err
|
|
443
|
+
? String((err as { description?: unknown }).description)
|
|
444
|
+
: String(err)
|
|
445
|
+
const low = desc.toLowerCase()
|
|
446
|
+
// "message is not modified" — the anchor already shows this suffix.
|
|
447
|
+
// The card is correct; count it as an edit and move on silently.
|
|
448
|
+
if (low.includes('not modified')) {
|
|
449
|
+
activeDeps!.emitMetric?.({
|
|
450
|
+
kind: 'pending_progress_edited',
|
|
451
|
+
chatKey: key,
|
|
452
|
+
elapsedMs: elapsed,
|
|
453
|
+
})
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
// Message / chat gone, or the 48h edit window closed. The anchor
|
|
457
|
+
// is dead — stop retrying it (clear state) so the tick isn't
|
|
458
|
+
// hammering a non-existent message every EDIT_INTERVAL_MS. The
|
|
459
|
+
// next outbound reply re-establishes a fresh anchor. Silent: no
|
|
460
|
+
// card to update is not a liveness-logic error.
|
|
461
|
+
if (
|
|
462
|
+
low.includes('not found') ||
|
|
463
|
+
low.includes("can't be edited") ||
|
|
464
|
+
low.includes('cannot be edited') ||
|
|
465
|
+
low.includes('not enough rights')
|
|
466
|
+
) {
|
|
467
|
+
clearPending(key, 'stale_turn')
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
// 429 / transient network blip — leave state intact; the next tick
|
|
471
|
+
// retries. No stderr: a transport hiccup on a best-effort card is
|
|
472
|
+
// not a logic error, and the production wiring already applies
|
|
473
|
+
// retry_after backoff upstream.
|
|
439
474
|
})
|
|
440
475
|
}
|
|
441
476
|
}
|