switchroom 0.15.45 → 0.16.5
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 +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -84,6 +84,13 @@ export function detectModelUnavailable(
|
|
|
84
84
|
// resets 8:50am (Australia/Melbourne)".
|
|
85
85
|
'hit your limit',
|
|
86
86
|
'hit the limit',
|
|
87
|
+
// SESSION-cap wording: "You've hit your session limit · resets 5pm".
|
|
88
|
+
// A session cap is a quota exhaustion that frees in HOURS (its reset is a
|
|
89
|
+
// bare time-of-day, see parseResetTime's time-only branch) — recognising
|
|
90
|
+
// it here is what lets the time-only reset parse fire and keeps a
|
|
91
|
+
// session-capped account from the +7d weekly bench.
|
|
92
|
+
'session limit',
|
|
93
|
+
'session cap',
|
|
87
94
|
]
|
|
88
95
|
if (quotaSignals.some(s => lower.includes(s))) {
|
|
89
96
|
const resetAt = parseResetTime(sample)
|
|
@@ -192,9 +199,126 @@ function parseResetTime(text: string, parseTimeNow: Date = new Date()): Date | u
|
|
|
192
199
|
if (!Number.isNaN(d.getTime())) return d
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
// "resets 5pm (Australia/Melbourne)" / "resets 8:50am" / "resets 17:00 (UTC)"
|
|
203
|
+
// SESSION-cap wording: a time of day with NO month/day. This frees in
|
|
204
|
+
// HOURS, not a week — without this branch it falls through to undefined,
|
|
205
|
+
// and the 429 inference path then applies resolveExhaustUntil's +7d weekly
|
|
206
|
+
// floor, benching a session-capped account for a week. Must sit AFTER the
|
|
207
|
+
// calendar branch so "resets May 3, 11am" never matches here. The leading
|
|
208
|
+
// negative lookahead `(?!...)` rejects a month name so a date-bearing
|
|
209
|
+
// string can't fall into this time-only branch.
|
|
210
|
+
const timeOnly = text.match(
|
|
211
|
+
/resets?\s+(?:at\s+)?(?!(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b)(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i,
|
|
212
|
+
)
|
|
213
|
+
if (timeOnly) {
|
|
214
|
+
const d = resolveNextWallClock(
|
|
215
|
+
Number(timeOnly[1]),
|
|
216
|
+
timeOnly[2] ? Number(timeOnly[2]) : 0,
|
|
217
|
+
timeOnly[3]?.toLowerCase(),
|
|
218
|
+
timeOnly[4]?.trim(),
|
|
219
|
+
parseTimeNow,
|
|
220
|
+
)
|
|
221
|
+
if (d != null) return d
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolve a bare wall-clock time ("5pm", "8:50am", "17:00") to the NEXT
|
|
229
|
+
* occurrence of that time, tz-aware. Returns the soonest future Date (rolls
|
|
230
|
+
* to tomorrow when the time has already passed today). Null on bad input
|
|
231
|
+
* (out-of-range hour/minute or an unknown tz). When `tz` is omitted the
|
|
232
|
+
* time is interpreted in UTC (best-effort) — Anthropic's strings normally
|
|
233
|
+
* carry the IANA tz in parens, e.g. "(Australia/Melbourne)".
|
|
234
|
+
*/
|
|
235
|
+
function resolveNextWallClock(
|
|
236
|
+
hour12or24: number,
|
|
237
|
+
minute: number,
|
|
238
|
+
ampm: string | undefined,
|
|
239
|
+
tz: string | undefined,
|
|
240
|
+
nowDate: Date,
|
|
241
|
+
): Date | undefined {
|
|
242
|
+
let hour = hour12or24
|
|
243
|
+
if (ampm === 'pm' && hour < 12) hour += 12
|
|
244
|
+
if (ampm === 'am' && hour === 12) hour = 0
|
|
245
|
+
if (!Number.isFinite(hour) || hour > 23 || hour < 0) return undefined
|
|
246
|
+
if (!Number.isFinite(minute) || minute > 59 || minute < 0) return undefined
|
|
247
|
+
const nowMs = nowDate.getTime()
|
|
248
|
+
// Walk today and the next two days (DST-safe span) and pick the first
|
|
249
|
+
// occurrence strictly in the future relative to now.
|
|
250
|
+
const base = new Date(nowMs)
|
|
251
|
+
for (let dayOffset = 0; dayOffset <= 2; dayOffset++) {
|
|
252
|
+
// Derive the y/m/d for `dayOffset` days from now IN THE TARGET TZ, so the
|
|
253
|
+
// wall-clock date we resolve is the tz's calendar date, not the container's.
|
|
254
|
+
const dateParts = tzDateParts(new Date(nowMs + dayOffset * 86_400_000), tz)
|
|
255
|
+
if (dateParts == null) return undefined
|
|
256
|
+
const epoch = wallClockToEpoch(
|
|
257
|
+
dateParts.year, dateParts.month, dateParts.day, hour, minute, tz,
|
|
258
|
+
)
|
|
259
|
+
if (epoch != null && epoch > nowMs) return new Date(epoch)
|
|
260
|
+
}
|
|
261
|
+
// Fallback: shouldn't happen, but keep the function total.
|
|
262
|
+
void base
|
|
195
263
|
return undefined
|
|
196
264
|
}
|
|
197
265
|
|
|
266
|
+
/** The y/m/d of `d` as seen in `tz` (UTC when tz omitted). Null on bad tz. */
|
|
267
|
+
function tzDateParts(
|
|
268
|
+
d: Date,
|
|
269
|
+
tz: string | undefined,
|
|
270
|
+
): { year: number; month: number; day: number } | null {
|
|
271
|
+
if (!tz) {
|
|
272
|
+
return { year: d.getUTCFullYear(), month: d.getUTCMonth(), day: d.getUTCDate() }
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
276
|
+
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
277
|
+
})
|
|
278
|
+
const parts = Object.fromEntries(
|
|
279
|
+
fmt.formatToParts(d).filter((p) => p.type !== 'literal').map((p) => [p.type, p.value]),
|
|
280
|
+
)
|
|
281
|
+
return {
|
|
282
|
+
year: Number(parts.year),
|
|
283
|
+
month: Number(parts.month) - 1,
|
|
284
|
+
day: Number(parts.day),
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Convert a wall-clock time in an IANA tz to epoch-ms (null if the tz is
|
|
293
|
+
* unknown). Resolves the tz's offset AT that date via Intl, so it is correct
|
|
294
|
+
* across DST — NOT `new Date(localString)`, which assumes the container TZ.
|
|
295
|
+
* Mirrors wedge-watchdog.ts's helper of the same name (kept local to keep
|
|
296
|
+
* this module dependency-free / pure-testable).
|
|
297
|
+
*/
|
|
298
|
+
function wallClockToEpoch(
|
|
299
|
+
year: number, month: number, day: number, hour: number, minute: number, tz: string | undefined,
|
|
300
|
+
): number | null {
|
|
301
|
+
const asUtc = Date.UTC(year, month, day, hour, minute, 0)
|
|
302
|
+
if (!tz) return asUtc // no tz given → best-effort UTC
|
|
303
|
+
try {
|
|
304
|
+
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
305
|
+
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
306
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
|
307
|
+
})
|
|
308
|
+
const parts = Object.fromEntries(
|
|
309
|
+
fmt.formatToParts(new Date(asUtc)).filter((p) => p.type !== 'literal').map((p) => [p.type, p.value]),
|
|
310
|
+
)
|
|
311
|
+
const shown = Date.UTC(
|
|
312
|
+
Number(parts.year), Number(parts.month) - 1, Number(parts.day),
|
|
313
|
+
Number(parts.hour) % 24, Number(parts.minute), Number(parts.second),
|
|
314
|
+
)
|
|
315
|
+
const offset = shown - asUtc // how far ahead the tz wall clock is of UTC
|
|
316
|
+
return asUtc - offset
|
|
317
|
+
} catch {
|
|
318
|
+
return null // unknown tz
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
198
322
|
function parseRelativeDuration(s: string): number | null {
|
|
199
323
|
// "2h 15m" / "30m" / "45 seconds"
|
|
200
324
|
let total = 0
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reducer-side narrative dedup gate (the correctness core of the
|
|
3
|
+
* JSONL-text-narrative primitive).
|
|
4
|
+
*
|
|
5
|
+
* A `text` / `sub_agent_text` JSONL block is one of two things:
|
|
6
|
+
*
|
|
7
|
+
* 1. DRAFT-THEN-SEND — the model composing its answer just before it
|
|
8
|
+
* calls `reply` / `stream_reply` with near-identical text. Surfacing
|
|
9
|
+
* it would double-print the answer (once as a transient narrative
|
|
10
|
+
* step, once as the canonical reply). It MUST be suppressed.
|
|
11
|
+
* 2. WORKING NARRATION — the agent's own commentary that is never sent
|
|
12
|
+
* to the user ("On it. Let me find the repo…", "Sent. Waiting on the
|
|
13
|
+
* build…"). It SHOULD be surfaced as a transient liveness step.
|
|
14
|
+
*
|
|
15
|
+
* A projector sees one JSONL line at a time and cannot know whether a
|
|
16
|
+
* later line is a reply tool_use, so the SHOW/SUPPRESS decision is a
|
|
17
|
+
* stateful, one-step-deferred decision made reducer-side (the gateway for
|
|
18
|
+
* the main agent, the subagent-watcher for sub/worker). This module is the
|
|
19
|
+
* pure, fully-unit-testable kernel of that decision — no I/O, no state of
|
|
20
|
+
* its own; the caller owns the `pendingNarrative` slot.
|
|
21
|
+
*
|
|
22
|
+
* The threshold heuristic deliberately matches the spirit of the #546
|
|
23
|
+
* outbound dedup (trim + lowercase + whitespace-collapse) so a draft and
|
|
24
|
+
* its reply compare equal the same way #546 considers them the same
|
|
25
|
+
* message.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Tools whose `input.text` IS the canonical answer surface. */
|
|
29
|
+
export const REPLY_TOOLS = new Set(['reply', 'stream_reply'])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Normalize for prefix comparison: strip markdown/HTML-ish emphasis,
|
|
33
|
+
* heading and quote marks, collapse whitespace, lowercase. Mirrors the
|
|
34
|
+
* #546 outbound-dedup normalization so a markdown-decorated draft and its
|
|
35
|
+
* plain reply compare equal.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeNarrative(s: string): string {
|
|
38
|
+
return s
|
|
39
|
+
.replace(/[*_`>#~]/g, '') // markdown emphasis / heading / quote marks
|
|
40
|
+
.replace(/\s+/g, ' ')
|
|
41
|
+
.trim()
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Longest-common-prefix ratio over the SHORTER of the two normalized strings. */
|
|
46
|
+
export function prefixSimilarity(a: string, b: string): number {
|
|
47
|
+
const x = normalizeNarrative(a)
|
|
48
|
+
const y = normalizeNarrative(b)
|
|
49
|
+
if (x.length === 0 || y.length === 0) return 0
|
|
50
|
+
const n = Math.min(x.length, y.length)
|
|
51
|
+
let i = 0
|
|
52
|
+
while (i < n && x[i] === y[i]) i++
|
|
53
|
+
return i / n
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The single tunable. Longest-common-PREFIX ratio (not Levenshtein) is
|
|
58
|
+
* deliberate: a draft shares a long head with the sent answer even when the
|
|
59
|
+
* model trims a trailing sentence before sending. 0.8 over the shorter
|
|
60
|
+
* string tolerates that trim while rejecting the "Sent. Waiting…" +
|
|
61
|
+
* different-reply case (short string, near-zero shared prefix). Exported so
|
|
62
|
+
* the test pins it — a silent retune breaks a test.
|
|
63
|
+
*/
|
|
64
|
+
export const DRAFT_SUPPRESS_THRESHOLD = 0.8
|
|
65
|
+
|
|
66
|
+
/** TRUE ⇒ this text block is a draft-then-send of `replyText`; SUPPRESS it. */
|
|
67
|
+
export function isDraftOfReply(textBlock: string, replyText: string): boolean {
|
|
68
|
+
return prefixSimilarity(textBlock, replyText) >= DRAFT_SUPPRESS_THRESHOLD
|
|
69
|
+
}
|
|
@@ -25,7 +25,29 @@
|
|
|
25
25
|
* the decision says CLAIM the slot — caller sets `firstPingAt`.
|
|
26
26
|
* - When the model requested silent, this module is a no-op.
|
|
27
27
|
*
|
|
28
|
+
* Notification ownership (R8 / PR-2). The bare "first ping wins" rule
|
|
29
|
+
* above has a residual failure: an interim ACK that pings first claims
|
|
30
|
+
* the turn's single slot, and the later SUBSTANTIVE answer is then
|
|
31
|
+
* downgraded to silent — "the reply is last but the phone never buzzed
|
|
32
|
+
* for the answer." To fix that without re-introducing model double-pings,
|
|
33
|
+
* the decision is now aware of WHO holds the slot and WHO is asking:
|
|
34
|
+
*
|
|
35
|
+
* - A SUBSTANTIVE final asking to ping while the slot is held by a
|
|
36
|
+
* NON-substantive (ack) send ⇒ do NOT suppress; let the answer ping
|
|
37
|
+
* and UPGRADE the slot to substantive (the answer owns the ping even
|
|
38
|
+
* though the ack already buzzed once — a deliberate, bounded second
|
|
39
|
+
* ping so the user is notified of the actual answer).
|
|
40
|
+
* - An ACK asking to ping while the slot is held by a SUBSTANTIVE send
|
|
41
|
+
* ⇒ suppress (no spurious double-ping AFTER the real answer).
|
|
42
|
+
* - A SUBSTANTIVE asking while the slot is held by a SUBSTANTIVE ⇒
|
|
43
|
+
* suppress (preserves the #1674 model-double-ping guard: answer +
|
|
44
|
+
* wrap-up should be one beep, not two).
|
|
45
|
+
* - An ACK while the slot is held by an ACK ⇒ suppress (unchanged).
|
|
46
|
+
*
|
|
28
47
|
* The slot is claimed BEFORE the actual send (caller responsibility).
|
|
48
|
+
* On a CLAIM or an UPGRADE the caller MUST set `firstPingAt` AND
|
|
49
|
+
* `firstPingWasSubstantive` ATOMICALLY (same synchronous block, no await
|
|
50
|
+
* between) so a racing second reply reads a consistent pair.
|
|
29
51
|
* Trade-off documented inline in `gateway.ts:executeReply`.
|
|
30
52
|
*/
|
|
31
53
|
|
|
@@ -39,6 +61,18 @@ export interface OverPingDecisionInput {
|
|
|
39
61
|
* has landed yet. Caller threads this through from
|
|
40
62
|
* `CurrentTurn.firstPingAt`. */
|
|
41
63
|
firstPingAt: number | null
|
|
64
|
+
/** True iff THIS reply is a substantive final answer (stream `done`,
|
|
65
|
+
* or text length ≥ FINAL_ANSWER_MIN_CHARS) — as opposed to a short
|
|
66
|
+
* interim ack. Caller computes via `isSubstantiveFinalReply`. Defaults
|
|
67
|
+
* to `false` (treat as a non-substantive ack) when omitted, which
|
|
68
|
+
* preserves the pre-PR-2 "first ping wins, the rest suppress" behaviour
|
|
69
|
+
* for callers that don't yet thread it. */
|
|
70
|
+
substantive?: boolean
|
|
71
|
+
/** True iff the send that CLAIMED the turn's ping slot was itself a
|
|
72
|
+
* substantive final answer. Caller threads this through from
|
|
73
|
+
* `CurrentTurn.firstPingWasSubstantive`. Meaningless (and ignored)
|
|
74
|
+
* when `firstPingAt == null`. Defaults to `false`. */
|
|
75
|
+
firstPingWasSubstantive?: boolean
|
|
42
76
|
/** Deterministic clock for tests; defaults to Date.now() in callers. */
|
|
43
77
|
nowMs: number
|
|
44
78
|
}
|
|
@@ -49,8 +83,18 @@ export interface OverPingDecision {
|
|
|
49
83
|
* violation by the model — caller should log + emit a metric. */
|
|
50
84
|
suppress: boolean
|
|
51
85
|
/** True iff the caller should claim the slot —
|
|
52
|
-
* `turn.firstPingAt = nowMs
|
|
86
|
+
* `turn.firstPingAt = nowMs` AND
|
|
87
|
+
* `turn.firstPingWasSubstantive = substantive`. Mutually exclusive
|
|
88
|
+
* with `suppress`. Set both on a fresh claim (no prior ping) and on
|
|
89
|
+
* an UPGRADE (a substantive answer pinging over an ack's slot). */
|
|
53
90
|
claimSlot: boolean
|
|
91
|
+
/** True iff this is an UPGRADE — a substantive final answer claiming
|
|
92
|
+
* the ping slot that was previously held by a NON-substantive ack.
|
|
93
|
+
* The answer pings even though the ack already buzzed once. Implied
|
|
94
|
+
* by `claimSlot && firstPingAt != null` but surfaced explicitly so
|
|
95
|
+
* the caller can log/meter the (intentional) second ping distinctly
|
|
96
|
+
* from a normal first claim. Always false on a suppress or a no-op. */
|
|
97
|
+
upgrade: boolean
|
|
54
98
|
/** When `suppress` is true, how long the first ping has been
|
|
55
99
|
* "active" (ms since `firstPingAt`). Caller surfaces this in the
|
|
56
100
|
* log + metric for forensic analysis (e.g. tight rapid double-pings
|
|
@@ -63,18 +107,40 @@ export interface OverPingDecision {
|
|
|
63
107
|
* No mutation, no IO, deterministic under a fixed `nowMs`.
|
|
64
108
|
*/
|
|
65
109
|
export function decideOverPing(input: OverPingDecisionInput): OverPingDecision {
|
|
110
|
+
const substantive = input.substantive === true
|
|
111
|
+
const firstPingWasSubstantive = input.firstPingWasSubstantive === true
|
|
112
|
+
|
|
66
113
|
if (!input.modelRequestedPing) {
|
|
67
114
|
// Model already chose silent — nothing for the safety net to do.
|
|
68
|
-
return { suppress: false, claimSlot: false, sinceFirstPingMs: null }
|
|
115
|
+
return { suppress: false, claimSlot: false, upgrade: false, sinceFirstPingMs: null }
|
|
69
116
|
}
|
|
70
117
|
if (input.firstPingAt != null) {
|
|
71
|
-
//
|
|
118
|
+
// The turn's ping slot is already held. WHO holds it and WHO is
|
|
119
|
+
// asking decides whether this is a notification-ownership UPGRADE or
|
|
120
|
+
// a double-ping to suppress (see the module doc-comment for the full
|
|
121
|
+
// matrix).
|
|
122
|
+
if (substantive && !firstPingWasSubstantive) {
|
|
123
|
+
// The substantive ANSWER is pinging over a slot held by an ack.
|
|
124
|
+
// Let it ping and upgrade the slot to substantive — the answer
|
|
125
|
+
// owns the turn's notification, not the earlier ack.
|
|
126
|
+
return {
|
|
127
|
+
suppress: false,
|
|
128
|
+
claimSlot: true,
|
|
129
|
+
upgrade: true,
|
|
130
|
+
sinceFirstPingMs: null,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Every other slot-held case is a double-ping to suppress:
|
|
134
|
+
// - ack over substantive: a spurious wrap-up after the real answer
|
|
135
|
+
// - substantive over substantive: the #1674 answer+wrap-up guard
|
|
136
|
+
// - ack over ack: the original one-ping-per-turn behaviour
|
|
72
137
|
return {
|
|
73
138
|
suppress: true,
|
|
74
139
|
claimSlot: false,
|
|
140
|
+
upgrade: false,
|
|
75
141
|
sinceFirstPingMs: input.nowMs - input.firstPingAt,
|
|
76
142
|
}
|
|
77
143
|
}
|
|
78
144
|
// First ping this turn — let it through and claim the slot.
|
|
79
|
-
return { suppress: false, claimSlot: true, sinceFirstPingMs: null }
|
|
145
|
+
return { suppress: false, claimSlot: true, upgrade: false, sinceFirstPingMs: null }
|
|
80
146
|
}
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
|
43
|
-
"url": "https://github.com/
|
|
43
|
+
"url": "https://github.com/switchroom/switchroom.git",
|
|
44
44
|
"directory": "telegram-plugin"
|
|
45
45
|
},
|
|
46
|
-
"homepage": "https://github.com/
|
|
46
|
+
"homepage": "https://github.com/switchroom/switchroom/tree/main/telegram-plugin#readme",
|
|
47
47
|
"bugs": {
|
|
48
|
-
"url": "https://github.com/
|
|
48
|
+
"url": "https://github.com/switchroom/switchroom/issues"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
|
@@ -284,6 +284,18 @@ export function noteTurnEnd(key: string): void {
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/**
|
|
288
|
+
* True when the current turn for `key` dispatched async background work
|
|
289
|
+
* (Agent / Task / Bash run_in_background:true) but the turn has not yet ended
|
|
290
|
+
* with a cleared pending flag. Used by the feed-survival predicate so the
|
|
291
|
+
* orphaned-reply backstop and silence-poke teardown are deferred while a
|
|
292
|
+
* detached background process is still running — even after inFlight empties
|
|
293
|
+
* when the near-instant tool_result (e.g. the Bash background handle) returns.
|
|
294
|
+
*/
|
|
295
|
+
export function hasPendingAsyncDispatch(key: string): boolean {
|
|
296
|
+
return stateByKey.get(key)?.pending === true
|
|
297
|
+
}
|
|
298
|
+
|
|
287
299
|
/**
|
|
288
300
|
* Clear pending-progress for a chat — reasons:
|
|
289
301
|
* 'inbound' — user sent a new message, they're re-engaged
|
|
@@ -91,7 +91,7 @@ export function resolveScopedAllowChoices(
|
|
|
91
91
|
|
|
92
92
|
// ── File tools: this exact path vs any file.
|
|
93
93
|
if (FILE_TOOLS.has(toolName)) {
|
|
94
|
-
const path = filePathFrom(input);
|
|
94
|
+
const path = filePathFrom(input, inputPreview);
|
|
95
95
|
const broad: ScopeOption = { rule: toolName, buttonLabel: "Any file", broad: true };
|
|
96
96
|
if (path) {
|
|
97
97
|
return {
|
|
@@ -163,9 +163,36 @@ function resolveSkillName(input: Record<string, unknown>): string | null {
|
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
function filePathFrom(
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
function filePathFrom(
|
|
167
|
+
input: Record<string, unknown> | null,
|
|
168
|
+
rawPreview?: string,
|
|
169
|
+
): string | null {
|
|
170
|
+
if (input) {
|
|
171
|
+
const p = readString(input, "file_path") ?? readString(input, "notebook_path");
|
|
172
|
+
if (p) return p;
|
|
173
|
+
}
|
|
174
|
+
// Claude Code truncates inputPreview to 200 chars, making the surrounding
|
|
175
|
+
// JSON invalid for Edit/Write (old_string/new_string push it past 200).
|
|
176
|
+
// "file_path" is the first key, so its value is intact in the truncated
|
|
177
|
+
// prefix — extract it with a lenient regex on the raw string.
|
|
178
|
+
if (rawPreview) return extractFilePathFromRaw(rawPreview);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Regex-based fallback to extract "file_path" or "notebook_path" from a raw
|
|
184
|
+
* (possibly truncated / invalid-JSON) inputPreview string. JSON-unescapes the
|
|
185
|
+
* captured value. Returns null when neither key is present or value is empty.
|
|
186
|
+
*/
|
|
187
|
+
function extractFilePathFromRaw(raw: string): string | null {
|
|
188
|
+
const m = /"(?:file_path|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(raw);
|
|
189
|
+
if (!m) return null;
|
|
190
|
+
try {
|
|
191
|
+
const value = JSON.parse(`"${m[1]}"`) as string;
|
|
192
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
169
196
|
}
|
|
170
197
|
|
|
171
198
|
/**
|
|
@@ -274,7 +301,7 @@ export function matchesAllowRule(
|
|
|
274
301
|
return bashFirstToken(cmd) === m[1];
|
|
275
302
|
}
|
|
276
303
|
if (FILE_TOOLS.has(ruleTool)) {
|
|
277
|
-
return filePathFrom(input) === arg;
|
|
304
|
+
return filePathFrom(input, inputPreview) === arg;
|
|
278
305
|
}
|
|
279
306
|
return false;
|
|
280
307
|
}
|
|
@@ -77,6 +77,21 @@ const INTERNAL_MCP_SERVERS = new Set([
|
|
|
77
77
|
"switchroom-telegram",
|
|
78
78
|
]);
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* hostd fleet verbs that take a target agent `name` as a required arg. The
|
|
82
|
+
* approval card MUST name WHICH agent is targeted (#2469) — "restart an
|
|
83
|
+
* agent" with no name leaves the operator blind. We interpolate the target
|
|
84
|
+
* into the curated phrase: "restart an agent in the fleet" → "restart agent
|
|
85
|
+
* `carrie` in the fleet". Stays generic when `name` is absent (never crash).
|
|
86
|
+
*/
|
|
87
|
+
const HOSTD_AGENT_TARGET_VERBS = new Set([
|
|
88
|
+
"mcp__hostd__agent_restart",
|
|
89
|
+
"mcp__hostd__agent_start",
|
|
90
|
+
"mcp__hostd__agent_stop",
|
|
91
|
+
"mcp__hostd__agent_logs",
|
|
92
|
+
"mcp__hostd__agent_exec",
|
|
93
|
+
]);
|
|
94
|
+
|
|
80
95
|
/**
|
|
81
96
|
* Build the multi-line card body for an approval prompt.
|
|
82
97
|
*
|
|
@@ -86,10 +101,23 @@ const INTERNAL_MCP_SERVERS = new Set([
|
|
|
86
101
|
* Output is HTML-escaped for `parse_mode: 'HTML'`. The agent name is
|
|
87
102
|
* capitalized for the sentence; dropped (with "wants to") when null —
|
|
88
103
|
* the bridge client can be anonymous during early-boot edge cases.
|
|
104
|
+
*
|
|
105
|
+
* The `why:` line is the CALLER's stated rationale — the `reason`/`why`
|
|
106
|
+
* argument on the tool input, NOT the tool's static JSONSchema
|
|
107
|
+
* `description`. The schema description is documentation (it can contain
|
|
108
|
+
* literal tokens like `$SWITCHROOM_AGENT_NAME`), so surfacing it as the
|
|
109
|
+
* "why" reads like an un-interpolated variable and discards the agent's
|
|
110
|
+
* actual reason (#2469). We only fall back to "not provided" — never to
|
|
111
|
+
* the schema description.
|
|
89
112
|
*/
|
|
90
113
|
export function formatPermissionCardBody(opts: {
|
|
91
114
|
toolName: string;
|
|
92
115
|
inputPreview: string | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* The tool's static JSONSchema description. Retained for the signature
|
|
118
|
+
* (callers still pass it) but deliberately NOT used as the `why:` line —
|
|
119
|
+
* see #2469. The caller's rationale comes from the input args instead.
|
|
120
|
+
*/
|
|
93
121
|
description: string | undefined;
|
|
94
122
|
agentName: string | null;
|
|
95
123
|
}): string {
|
|
@@ -104,7 +132,10 @@ export function formatPermissionCardBody(opts: {
|
|
|
104
132
|
lines.push(`🔐 ${escapeTgHtml(capFirst(action))}`);
|
|
105
133
|
}
|
|
106
134
|
|
|
107
|
-
|
|
135
|
+
// why: the caller-supplied rationale (`reason`/`why` arg), never the
|
|
136
|
+
// static schema description (#2469).
|
|
137
|
+
const callerReason = callerSuppliedReason(opts.inputPreview);
|
|
138
|
+
const rawWhy = (callerReason ?? "").replace(/\s+/g, " ").trim();
|
|
108
139
|
const truncatedWhy =
|
|
109
140
|
rawWhy.length > DESCRIPTION_LINE_MAX
|
|
110
141
|
? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "…"
|
|
@@ -142,15 +173,15 @@ export function naturalAction(
|
|
|
142
173
|
case "Edit":
|
|
143
174
|
case "MultiEdit":
|
|
144
175
|
case "NotebookEdit": {
|
|
145
|
-
const f = fileBase(input);
|
|
176
|
+
const f = fileBase(input, inputPreview);
|
|
146
177
|
return f ? `edit: ${f}` : "edit files";
|
|
147
178
|
}
|
|
148
179
|
case "Write": {
|
|
149
|
-
const f = fileBase(input);
|
|
180
|
+
const f = fileBase(input, inputPreview);
|
|
150
181
|
return f ? `write: ${f}` : "write files";
|
|
151
182
|
}
|
|
152
183
|
case "Read": {
|
|
153
|
-
const f = fileBase(input);
|
|
184
|
+
const f = fileBase(input, inputPreview);
|
|
154
185
|
return f ? `read: ${f}` : "read files";
|
|
155
186
|
}
|
|
156
187
|
case "Bash": {
|
|
@@ -194,7 +225,7 @@ function naturalMcpAction(
|
|
|
194
225
|
const server = parts.length >= 2 ? parts[1]! : "";
|
|
195
226
|
const curated = MCP_TOOL_DESCRIPTIONS[toolName];
|
|
196
227
|
if (curated) {
|
|
197
|
-
const phrase = lowerFirst(curated);
|
|
228
|
+
const phrase = hostdAgentPhrase(toolName, input) ?? lowerFirst(curated);
|
|
198
229
|
return INTERNAL_MCP_SERVERS.has(server)
|
|
199
230
|
? phrase
|
|
200
231
|
: `${phrase} (${prettyMcpServer(server)})`;
|
|
@@ -217,6 +248,37 @@ function naturalMcpAction(
|
|
|
217
248
|
return `use ${toolName}`;
|
|
218
249
|
}
|
|
219
250
|
|
|
251
|
+
/**
|
|
252
|
+
* For the hostd `agent_*` fleet verbs, build an action phrase that NAMES the
|
|
253
|
+
* target agent (#2469) — "restart agent `carrie` in the fleet". The verb is
|
|
254
|
+
* derived from the tool name (`agent_restart` → "restart"); `agent_logs` /
|
|
255
|
+
* `agent_exec` get bespoke phrasing. Returns null when the tool isn't a
|
|
256
|
+
* name-targeted hostd verb or no `name` arg is present, so the caller falls
|
|
257
|
+
* back to the generic curated phrase (never crashes on a missing name).
|
|
258
|
+
*/
|
|
259
|
+
function hostdAgentPhrase(
|
|
260
|
+
toolName: string,
|
|
261
|
+
input: Record<string, unknown> | null,
|
|
262
|
+
): string | null {
|
|
263
|
+
if (!HOSTD_AGENT_TARGET_VERBS.has(toolName)) return null;
|
|
264
|
+
const name = input ? readString(input, "name") : null;
|
|
265
|
+
if (!name) return null;
|
|
266
|
+
switch (toolName) {
|
|
267
|
+
case "mcp__hostd__agent_restart":
|
|
268
|
+
return `restart agent \`${name}\` in the fleet`;
|
|
269
|
+
case "mcp__hostd__agent_start":
|
|
270
|
+
return `start agent \`${name}\` in the fleet`;
|
|
271
|
+
case "mcp__hostd__agent_stop":
|
|
272
|
+
return `stop agent \`${name}\` in the fleet`;
|
|
273
|
+
case "mcp__hostd__agent_logs":
|
|
274
|
+
return `read agent \`${name}\`'s container logs`;
|
|
275
|
+
case "mcp__hostd__agent_exec":
|
|
276
|
+
return `run a read-only inspection inside agent \`${name}\``;
|
|
277
|
+
default:
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
220
282
|
/**
|
|
221
283
|
* For a REST-wrapper MCP call ({ path, body?, query? }), build the action
|
|
222
284
|
* phrase "<VERB> <path> (<Server>)" — e.g. "POST /smtp/email (Brevo)". The
|
|
@@ -405,10 +467,43 @@ function resolveSkillName(input: Record<string, unknown>): string | null {
|
|
|
405
467
|
);
|
|
406
468
|
}
|
|
407
469
|
|
|
408
|
-
function fileBase(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
470
|
+
function fileBase(
|
|
471
|
+
input: Record<string, unknown> | null,
|
|
472
|
+
rawPreview?: string,
|
|
473
|
+
): string | null {
|
|
474
|
+
if (input) {
|
|
475
|
+
const p = readString(input, "file_path") ?? readString(input, "notebook_path");
|
|
476
|
+
if (p) return basename(p);
|
|
477
|
+
}
|
|
478
|
+
// Claude Code truncates inputPreview to 200 chars, making the surrounding
|
|
479
|
+
// JSON invalid (Edit/Write always exceed 200 chars once old_string/new_string
|
|
480
|
+
// are included). "file_path" is the first key, so its value is intact in the
|
|
481
|
+
// truncated prefix — extract it with a lenient regex on the raw string.
|
|
482
|
+
if (rawPreview) {
|
|
483
|
+
const p = extractFilePathFromRaw(rawPreview);
|
|
484
|
+
if (p) return basename(p);
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Regex-based fallback to extract "file_path" or "notebook_path" from a raw
|
|
491
|
+
* (possibly truncated / invalid-JSON) inputPreview string. JSON-unescapes the
|
|
492
|
+
* captured value so paths with backslashes or unicode escapes are returned
|
|
493
|
+
* correctly. Returns null when neither key is present or the captured value is
|
|
494
|
+
* empty.
|
|
495
|
+
*/
|
|
496
|
+
function extractFilePathFromRaw(raw: string): string | null {
|
|
497
|
+
// Match the first occurrence of "file_path" or "notebook_path".
|
|
498
|
+
const m = /"(?:file_path|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(raw);
|
|
499
|
+
if (!m) return null;
|
|
500
|
+
try {
|
|
501
|
+
// JSON.parse the quoted string literal so escape sequences are resolved.
|
|
502
|
+
const value = JSON.parse(`"${m[1]}"`) as string;
|
|
503
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
412
507
|
}
|
|
413
508
|
|
|
414
509
|
function lowerFirst(text: string): string {
|
|
@@ -447,6 +542,54 @@ function readString(input: Record<string, unknown>, key: string): string | null
|
|
|
447
542
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
448
543
|
}
|
|
449
544
|
|
|
545
|
+
/**
|
|
546
|
+
* The caller's stated rationale for a tool call — the `reason` (or `why`)
|
|
547
|
+
* argument it passed. This is the agent's actual justification, which is
|
|
548
|
+
* what belongs on the `why:` line of the approval card. Returns null when
|
|
549
|
+
* no reason was supplied (caller renders "not provided") — we never fall
|
|
550
|
+
* back to the tool's static schema description (#2469).
|
|
551
|
+
*/
|
|
552
|
+
function callerSuppliedReason(inputPreview: string | undefined): string | null {
|
|
553
|
+
const input = parseInput(inputPreview);
|
|
554
|
+
if (input) {
|
|
555
|
+
const fromJson = readString(input, "reason") ?? readString(input, "why");
|
|
556
|
+
if (fromJson) return fromJson;
|
|
557
|
+
}
|
|
558
|
+
// Truncation fallback (#2580 follow-up): upstream Claude Code truncates
|
|
559
|
+
// `inputPreview` to ~200 chars. For a tool whose first/largest key is a
|
|
560
|
+
// big blob (e.g. config_propose_edit's `unified_diff`), the truncated JSON
|
|
561
|
+
// is unparseable and the schema-required `reason` is lost — the card then
|
|
562
|
+
// renders "why: not provided" even though a reason WAS supplied. Mirror the
|
|
563
|
+
// `extractFilePathFromRaw` lenient-regex fallback so a `reason`/`why` value
|
|
564
|
+
// surviving in the truncated prefix is still recovered. (Reordering the
|
|
565
|
+
// schema so `reason` precedes the blob keeps it inside the 200-char prefix;
|
|
566
|
+
// this regex is what then reads it back out.)
|
|
567
|
+
if (inputPreview) {
|
|
568
|
+
const r = extractReasonFromRaw(inputPreview);
|
|
569
|
+
if (r) return r;
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Regex-based fallback to extract a `reason` or `why` value from a raw
|
|
576
|
+
* (possibly truncated / invalid-JSON) inputPreview string. Mirrors
|
|
577
|
+
* `extractFilePathFromRaw`: JSON-unescapes the captured value so a reason
|
|
578
|
+
* with quotes/backslashes/unicode escapes is returned correctly. Returns
|
|
579
|
+
* null when neither key is present or the captured value is empty/whitespace.
|
|
580
|
+
*/
|
|
581
|
+
export function extractReasonFromRaw(raw: string): string | null {
|
|
582
|
+
// Match the first occurrence of "reason" or "why".
|
|
583
|
+
const m = /"(?:reason|why)"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(raw);
|
|
584
|
+
if (!m) return null;
|
|
585
|
+
try {
|
|
586
|
+
const value = JSON.parse(`"${m[1]}"`) as string;
|
|
587
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
588
|
+
} catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
450
593
|
function skillBasenameFromPath(input: Record<string, unknown>): string | null {
|
|
451
594
|
const path = readString(input, "path") ?? readString(input, "skill_path");
|
|
452
595
|
if (!path) return null;
|
|
@@ -54,6 +54,15 @@ export type QuotaUtilization = {
|
|
|
54
54
|
representativeClaim: string | null;
|
|
55
55
|
overageStatus: string | null;
|
|
56
56
|
overageDisabledReason: string | null;
|
|
57
|
+
/**
|
|
58
|
+
* #2494 Bug C — header-presence markers. Mirror of the field in
|
|
59
|
+
* `src/auth/quota.ts` (kept in sync across the bundle boundary). The
|
|
60
|
+
* utilization fields are always numeric (a missing header coalesces to 0),
|
|
61
|
+
* so on their own they cannot tell a genuine 0% from a filled-0 thin probe.
|
|
62
|
+
* Optional → unset means "real probe" (legacy snapshots / fixtures).
|
|
63
|
+
*/
|
|
64
|
+
fiveHourUtilPresent?: boolean;
|
|
65
|
+
sevenDayUtilPresent?: boolean;
|
|
57
66
|
};
|
|
58
67
|
|
|
59
68
|
export type QuotaResult =
|
|
@@ -120,8 +129,12 @@ export function parseQuotaHeaders(headers: Headers): QuotaResult {
|
|
|
120
129
|
return {
|
|
121
130
|
ok: true,
|
|
122
131
|
data: {
|
|
132
|
+
// #2494 Bug C — coalesce missing window to 0 for back-compat but record
|
|
133
|
+
// which windows were actually present (both-absent returned ok:false).
|
|
123
134
|
fiveHourUtilizationPct: (fiveHour ?? 0) * 100,
|
|
124
135
|
sevenDayUtilizationPct: (sevenDay ?? 0) * 100,
|
|
136
|
+
fiveHourUtilPresent: fiveHour != null,
|
|
137
|
+
sevenDayUtilPresent: sevenDay != null,
|
|
125
138
|
fiveHourResetAt: parseEpochHeader(headers, "anthropic-ratelimit-unified-5h-reset"),
|
|
126
139
|
sevenDayResetAt: parseEpochHeader(headers, "anthropic-ratelimit-unified-7d-reset"),
|
|
127
140
|
representativeClaim: headers.get("anthropic-ratelimit-unified-representative-claim"),
|