switchroom 0.18.17 → 0.18.18
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 +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1401 -431
- package/telegram-plugin/dist/server.js +26 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +362 -71
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +436 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/worker-activity-feed.ts +509 -409
|
@@ -16994,6 +16994,27 @@ var init_plugin_logger = __esm(() => {
|
|
|
16994
16994
|
DEFAULT_LOG_PATH2 = join2(homedir2(), ".switchroom", "logs", "telegram-plugin.log");
|
|
16995
16995
|
ROTATE_AT_BYTES2 = 50 * 1024 * 1024;
|
|
16996
16996
|
});
|
|
16997
|
+
// raw-error-scrub.ts
|
|
16998
|
+
function extractRequestId(raw) {
|
|
16999
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
17000
|
+
return;
|
|
17001
|
+
const m = raw.match(/["']request[_-]?id["']\s*:\s*["']([A-Za-z0-9._-]+)["']/i) ?? raw.match(/\brequest[_-]?id[=:]\s*([A-Za-z0-9._-]+)/i);
|
|
17002
|
+
return m ? m[1] : undefined;
|
|
17003
|
+
}
|
|
17004
|
+
function truncateDetailPreservingRequestId(detail, max) {
|
|
17005
|
+
if (typeof detail !== "string")
|
|
17006
|
+
return "";
|
|
17007
|
+
if (detail.length <= max)
|
|
17008
|
+
return detail;
|
|
17009
|
+
const rid = extractRequestId(detail);
|
|
17010
|
+
const head = detail.slice(0, max);
|
|
17011
|
+
if (rid == null || head.includes(rid))
|
|
17012
|
+
return head;
|
|
17013
|
+
const suffix = ` request_id=${rid}`;
|
|
17014
|
+
const headBudget = Math.max(0, max - suffix.length);
|
|
17015
|
+
return `${detail.slice(0, headBudget)}${suffix}`;
|
|
17016
|
+
}
|
|
17017
|
+
|
|
16997
17018
|
// operator-events.ts
|
|
16998
17019
|
function classifyClaudeError(raw) {
|
|
16999
17020
|
try {
|
|
@@ -17376,6 +17397,10 @@ function projectTranscriptLine(line) {
|
|
|
17376
17397
|
const content = message?.content;
|
|
17377
17398
|
if (!Array.isArray(content))
|
|
17378
17399
|
return [];
|
|
17400
|
+
if (obj.isApiErrorMessage === true) {
|
|
17401
|
+
const mainModel2 = message?.model;
|
|
17402
|
+
return typeof mainModel2 === "string" && !isModelSentinel(mainModel2) ? [{ kind: "model", model: mainModel2 }] : [];
|
|
17403
|
+
}
|
|
17379
17404
|
const events = [];
|
|
17380
17405
|
const mainModel = message?.model;
|
|
17381
17406
|
if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
|
|
@@ -25140,7 +25165,7 @@ var init_bridge = __esm(async () => {
|
|
|
25140
25165
|
type: "operator_event",
|
|
25141
25166
|
kind: ev.kind,
|
|
25142
25167
|
agent: AGENT_NAME,
|
|
25143
|
-
detail: ev.detail
|
|
25168
|
+
detail: truncateDetailPreservingRequestId(ev.detail, 1000),
|
|
25144
25169
|
chatId: ""
|
|
25145
25170
|
});
|
|
25146
25171
|
} catch (err) {
|
|
@@ -81,12 +81,24 @@ export interface FleetFallbackResumeGate {
|
|
|
81
81
|
/**
|
|
82
82
|
* Decide whether to resume the dead turn after a successful swap. Call ONLY
|
|
83
83
|
* when the swap outcome was `'switched'`. Records the arm time on a 'resume'
|
|
84
|
-
* verdict so a follow-on swap within `singleFlightMs` is suppressed.
|
|
84
|
+
* verdict so a follow-on swap within `singleFlightMs` is suppressed. Equivalent
|
|
85
|
+
* to `peek()` followed by `arm()` on a 'resume' verdict.
|
|
85
86
|
*
|
|
86
87
|
* @param failedTurnStartedAtMs epoch-ms the failed turn began, or null when
|
|
87
88
|
* unknown (then the staleness guard is deferred to the boot path).
|
|
88
89
|
*/
|
|
89
90
|
decide(failedTurnStartedAtMs: number | null): ResumeDecision;
|
|
91
|
+
/**
|
|
92
|
+
* Evaluate the resume verdict WITHOUT arming the single-flight latch. Use when
|
|
93
|
+
* the caller must perform fallible work (e.g. write a consume-once carrier)
|
|
94
|
+
* BEFORE committing to a restart: peek → do the writes → `arm()` only on
|
|
95
|
+
* success, so a throwing write can never leave an armed latch with no pending
|
|
96
|
+
* restart (which would suppress a legitimate later resume for `singleFlightMs`).
|
|
97
|
+
*/
|
|
98
|
+
peek(failedTurnStartedAtMs: number | null): ResumeDecision;
|
|
99
|
+
/** Commit the single-flight arm (records "now" as the arm time). Pair with a
|
|
100
|
+
* prior `peek()` that returned 'resume'. */
|
|
101
|
+
arm(): void;
|
|
90
102
|
/** Test seam — reset to fresh state. Production code should not call this. */
|
|
91
103
|
reset(): void;
|
|
92
104
|
/** Test/debug — current internal state. */
|
|
@@ -105,7 +117,7 @@ export function createFleetFallbackResumeGate(
|
|
|
105
117
|
// -Infinity = never resumed. A concrete value arms the single-flight window.
|
|
106
118
|
let lastResumedAtMs = Number.NEGATIVE_INFINITY;
|
|
107
119
|
|
|
108
|
-
function
|
|
120
|
+
function peek(failedTurnStartedAtMs: number | null): ResumeDecision {
|
|
109
121
|
const now = nowFn();
|
|
110
122
|
// Guard 2 — single-flight. A second swap within the window does not
|
|
111
123
|
// re-arm; the in-flight restart owns the resume.
|
|
@@ -115,12 +127,23 @@ export function createFleetFallbackResumeGate(
|
|
|
115
127
|
if (failedTurnStartedAtMs != null && now - failedTurnStartedAtMs > maxAgeMs) {
|
|
116
128
|
return 'skip-stale';
|
|
117
129
|
}
|
|
118
|
-
lastResumedAtMs = now;
|
|
119
130
|
return 'resume';
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
function arm(): void {
|
|
134
|
+
lastResumedAtMs = nowFn();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function decide(failedTurnStartedAtMs: number | null): ResumeDecision {
|
|
138
|
+
const verdict = peek(failedTurnStartedAtMs);
|
|
139
|
+
if (verdict === 'resume') arm();
|
|
140
|
+
return verdict;
|
|
141
|
+
}
|
|
142
|
+
|
|
122
143
|
return {
|
|
123
144
|
decide,
|
|
145
|
+
peek,
|
|
146
|
+
arm,
|
|
124
147
|
reset() {
|
|
125
148
|
lastResumedAtMs = Number.NEGATIVE_INFINITY;
|
|
126
149
|
},
|
|
@@ -516,6 +516,55 @@ export function isHeldUndeliverable(
|
|
|
516
516
|
return pend.undeliverable != null
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
/** The fields the on-delivery reset reads and mutates. */
|
|
520
|
+
export interface DeliveredPermissionEntry {
|
|
521
|
+
undeliverable?: UndeliverableMark | null
|
|
522
|
+
redeliveryFailures?: number
|
|
523
|
+
/** When the operator's decision window began — the TTL is measured from here. */
|
|
524
|
+
startedAt: number
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* A HELD card just LANDED — the operator can finally see and tap it. Clear the
|
|
529
|
+
* hold marks and RESTART the TTL clock, as ONE shared decision used by BOTH
|
|
530
|
+
* gateway.ts and the outcome test's harness.
|
|
531
|
+
*
|
|
532
|
+
* WHY THIS IS SHARED CODE AND NOT AN INLINE BLOCK (#3128):
|
|
533
|
+
*
|
|
534
|
+
* The harness used to carry a PRIVATE copy of this reset (its own
|
|
535
|
+
* `live.startedAt = clock.now()`), so the behavioural test that proves the
|
|
536
|
+
* operator gets a FULL window after re-delivery drove the double, not the real
|
|
537
|
+
* gateway. Deleting `startedAt = Date.now()` from gateway.ts would have left that
|
|
538
|
+
* test GREEN — only a source-text grep noticed, and greps drift. Same placebo-pin
|
|
539
|
+
* class as the leash (#3123) and the never-auto-approve pin (#3126). Now the reset
|
|
540
|
+
* lives here, in one importable place, and both callers drive it: delete
|
|
541
|
+
* `entry.startedAt = now` below and the `(d2)` outcome test goes RED, because there
|
|
542
|
+
* is only one implementation to delete.
|
|
543
|
+
*
|
|
544
|
+
* RESET THE TTL CLOCK is load-bearing, not cosmetic. `startedAt` is when the
|
|
545
|
+
* agent asked; the TTL measures how long the operator had to answer. Until the
|
|
546
|
+
* card lands they have NOTHING to answer — it did not exist in any chat. Without
|
|
547
|
+
* the reset, a card held through a 4.6h ban lands already-expired against a 60-min
|
|
548
|
+
* TTL and the very next reaper tick auto-denies it: the silent denial would be
|
|
549
|
+
* MOVED, not removed.
|
|
550
|
+
*
|
|
551
|
+
* Returns true when this was a HELD-card recovery — the caller should reconcile
|
|
552
|
+
* the off-Telegram surface and log the re-delivery. Returns false for a normal
|
|
553
|
+
* first delivery (the entry was never held), where there is nothing to reset.
|
|
554
|
+
*
|
|
555
|
+
* @see reference/invariants.md § no-self-escalation, § on-leash
|
|
556
|
+
*/
|
|
557
|
+
export function applyDeliveredHoldReset(
|
|
558
|
+
entry: DeliveredPermissionEntry,
|
|
559
|
+
now: number,
|
|
560
|
+
): boolean {
|
|
561
|
+
if (entry.undeliverable == null) return false
|
|
562
|
+
entry.undeliverable = null
|
|
563
|
+
entry.redeliveryFailures = 0
|
|
564
|
+
entry.startedAt = now
|
|
565
|
+
return true
|
|
566
|
+
}
|
|
567
|
+
|
|
519
568
|
/**
|
|
520
569
|
* Per-tick re-delivery cap (PR 2).
|
|
521
570
|
*
|
|
@@ -32,11 +32,15 @@
|
|
|
32
32
|
* claude itself down — where a bounce is either premature or
|
|
33
33
|
* futile); the timer re-arms so a slow-booting claude gets the full
|
|
34
34
|
* grace window again once it appears;
|
|
35
|
-
* - cron-session bridges (`<agent>-cron`)
|
|
36
|
-
* (recall.py one-shots, pre-handshake connects)
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* - cron-session bridges (`<agent>-cron`), anonymous IPC clients
|
|
36
|
+
* (recall.py one-shots, pre-handshake connects), AND secondary/relay
|
|
37
|
+
* clients that register a DIFFERENT agent name than this gateway serves
|
|
38
|
+
* (e.g. an `overlord-relay` connecting into another agent's gateway
|
|
39
|
+
* socket — #3086) neither satisfy nor re-arm the watchdog: only the
|
|
40
|
+
* gateway's OWN primary bridge (agentName === $SWITCHROOM_AGENT_NAME)
|
|
41
|
+
* counts. The gating lives INSIDE noteBridge* so a gateway handler
|
|
42
|
+
* refactor can't silently reintroduce the bounce-after-cron-fire or the
|
|
43
|
+
* bounce-after-relay-disconnect false positive;
|
|
40
44
|
* - loud, structured escalation log line including a fresh
|
|
41
45
|
* bridge-crash.log tail (PR #3037 breadcrumbs) so the operator sees
|
|
42
46
|
* WHY from the supervisor log alone.
|
|
@@ -352,6 +356,16 @@ export interface BridgeDeadWatchdogOpts {
|
|
|
352
356
|
* unref (the watchdog must never keep the gateway process alive). */
|
|
353
357
|
setTimer?: (fn: () => void, ms: number) => unknown
|
|
354
358
|
clearTimer?: (handle: unknown) => void
|
|
359
|
+
/** The agent identity THIS gateway serves ($SWITCHROOM_AGENT_NAME).
|
|
360
|
+
* Only a bridge client registering under this exact name is the
|
|
361
|
+
* primary bridge whose presence/absence drives the watchdog. Secondary
|
|
362
|
+
* or relay clients (a different named identity connecting into this
|
|
363
|
+
* gateway's socket — e.g. `overlord-relay`, #3086) are ignored on both
|
|
364
|
+
* the register and disconnect paths. When unset/empty (a misconfigured
|
|
365
|
+
* gateway with no agent name), the watchdog falls back to the pre-#3086
|
|
366
|
+
* test — any named non-cron client counts — so a genuine bridge death is
|
|
367
|
+
* still caught rather than silently un-guarded. */
|
|
368
|
+
selfAgentName?: string
|
|
355
369
|
/** Injectable clock (marker ts + crash-log freshness). */
|
|
356
370
|
nowMs?: () => number
|
|
357
371
|
/** Injectable marker writer (tests avoid real fs). */
|
|
@@ -365,15 +379,18 @@ export interface BridgeDeadWatchdog {
|
|
|
365
379
|
* when the cross-boot streak cap is already reached (stands down with
|
|
366
380
|
* an audit line instead). */
|
|
367
381
|
arm: () => void
|
|
368
|
-
/** A bridge client registered. Only
|
|
369
|
-
*
|
|
370
|
-
* anonymous clients (agentName null)
|
|
371
|
-
*
|
|
382
|
+
/** A bridge client registered. Only THIS gateway's OWN primary bridge
|
|
383
|
+
* (agentName === selfAgentName, named, non-cron) satisfies the watchdog
|
|
384
|
+
* — cron sessions (`<agent>-cron`), anonymous clients (agentName null),
|
|
385
|
+
* and secondary/relay clients registering a different name (#3086) are
|
|
386
|
+
* ignored HERE so a gateway handler refactor can't reorder the gating
|
|
387
|
+
* away (review finding 5). */
|
|
372
388
|
noteBridgeRegistered: (agentName: string | null | undefined) => void
|
|
373
|
-
/** A bridge client disconnected. Re-arms the grace window only for
|
|
374
|
-
*
|
|
375
|
-
* and never reconnects also escalates
|
|
376
|
-
*
|
|
389
|
+
/** A bridge client disconnected. Re-arms the grace window only for THIS
|
|
390
|
+
* gateway's own primary bridge (same internal gating), so a bridge that
|
|
391
|
+
* dies AFTER boot and never reconnects also escalates — while a
|
|
392
|
+
* transient relay/secondary client's disconnect (#3086) is a no-op.
|
|
393
|
+
* Still capped by the once-per-boot fuse. */
|
|
377
394
|
noteBridgeDisconnected: (agentName: string | null | undefined) => void
|
|
378
395
|
/** Evaluate now (the timer body — exposed for tests). Returns the
|
|
379
396
|
* decision taken. */
|
|
@@ -384,9 +401,34 @@ export interface BridgeDeadWatchdog {
|
|
|
384
401
|
hasEscalated: () => boolean
|
|
385
402
|
}
|
|
386
403
|
|
|
387
|
-
/**
|
|
388
|
-
|
|
389
|
-
|
|
404
|
+
/**
|
|
405
|
+
* Is this client identity THIS gateway's OWN primary main-agent bridge?
|
|
406
|
+
*
|
|
407
|
+
* A named, non-cron client whose name matches the gateway's own agent
|
|
408
|
+
* identity (`selfAgentName` = $SWITCHROOM_AGENT_NAME). A switchroom gateway
|
|
409
|
+
* serves exactly one agent, and its real bridge registers under exactly that
|
|
410
|
+
* name (see bridge/bridge.ts — `agentName: AGENT_NAME`). Secondary or relay
|
|
411
|
+
* clients (e.g. an `overlord-relay` that connects into another agent's
|
|
412
|
+
* gateway socket, #3086) register a DIFFERENT name; they are named and
|
|
413
|
+
* non-cron but are NOT this gateway's primary bridge, so their register /
|
|
414
|
+
* disconnect must neither satisfy nor re-arm the watchdog — otherwise a
|
|
415
|
+
* transient relay disconnect marks the (still-alive) primary bridge dead and
|
|
416
|
+
* bounces a healthy container.
|
|
417
|
+
*
|
|
418
|
+
* When `selfAgentName` is unset/empty (misconfigured gateway) we cannot
|
|
419
|
+
* identity-match, so fall back to the pre-#3086 test — any named non-cron
|
|
420
|
+
* client counts — keeping the watchdog protective rather than un-guarded.
|
|
421
|
+
*/
|
|
422
|
+
function isRealBridgeIdentity(
|
|
423
|
+
agentName: string | null | undefined,
|
|
424
|
+
selfAgentName: string | null | undefined,
|
|
425
|
+
): boolean {
|
|
426
|
+
if (agentName == null || agentName.length === 0) return false
|
|
427
|
+
if (isCronIdentity(agentName)) return false
|
|
428
|
+
if (selfAgentName != null && selfAgentName.length > 0) {
|
|
429
|
+
return agentName === selfAgentName
|
|
430
|
+
}
|
|
431
|
+
return true
|
|
390
432
|
}
|
|
391
433
|
|
|
392
434
|
export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDeadWatchdog {
|
|
@@ -404,6 +446,7 @@ export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDe
|
|
|
404
446
|
opts.readCrashTail ?? ((p: string, t: number) => readFreshCrashLogTail(p, { nowMs: t }))
|
|
405
447
|
const priorStreak = opts.priorStreak ?? 0
|
|
406
448
|
const maxConsecutive = opts.maxConsecutive ?? MAX_CONSECUTIVE_ESCALATIONS
|
|
449
|
+
const selfAgentName = opts.selfAgentName
|
|
407
450
|
|
|
408
451
|
let timer: unknown = null
|
|
409
452
|
let bridgeRegistered = false
|
|
@@ -528,13 +571,13 @@ export function createBridgeDeadWatchdog(opts: BridgeDeadWatchdogOpts): BridgeDe
|
|
|
528
571
|
return {
|
|
529
572
|
arm: armInternal,
|
|
530
573
|
noteBridgeRegistered: (agentName) => {
|
|
531
|
-
if (!isRealBridgeIdentity(agentName)) return
|
|
574
|
+
if (!isRealBridgeIdentity(agentName, selfAgentName)) return
|
|
532
575
|
bridgeRegistered = true
|
|
533
576
|
bridgeEverRegistered = true
|
|
534
577
|
cancel()
|
|
535
578
|
},
|
|
536
579
|
noteBridgeDisconnected: (agentName) => {
|
|
537
|
-
if (!isRealBridgeIdentity(agentName)) return
|
|
580
|
+
if (!isRealBridgeIdentity(agentName, selfAgentName)) return
|
|
538
581
|
bridgeRegistered = false
|
|
539
582
|
armInternal()
|
|
540
583
|
},
|