switchroom 0.19.1 → 0.19.3
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 +31 -1
- package/dist/auth-broker/index.js +565 -48
- package/dist/cli/autoaccept-poll.js +31 -1
- package/dist/cli/drive-write-pretool.mjs +32 -2
- package/dist/cli/ms-365-write-pretool.mjs +32 -2
- package/dist/cli/switchroom.js +1148 -274
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +143 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1427 -689
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +141 -158
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +309 -1
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +78 -12
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -313,6 +313,40 @@ export function withStoreLock<T>(
|
|
|
313
313
|
return run
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Per-pinKey async serial lock (F2, invisible-worker-cards review).
|
|
318
|
+
*
|
|
319
|
+
* The gateway's status-pin reconcile reads its `prev` claim from an in-memory
|
|
320
|
+
* map at the TOP of the reconcile, then awaits the persist+pin. Two overlapping
|
|
321
|
+
* reconciles for the SAME key could each capture a stale `prev`: a turn-end
|
|
322
|
+
* `{pinned:false}` reading prev=null no-ops and clears the durable row while a
|
|
323
|
+
* flood-delayed open-pin lands right after — a stuck pin with NO on-disk record.
|
|
324
|
+
*
|
|
325
|
+
* Chaining every reconcile for one pinKey through this tail map guarantees the
|
|
326
|
+
* NEXT reconcile reads `prev` only after the prior one for that key has fully
|
|
327
|
+
* settled (its in-memory Maps updated), so the pin decision always sees the true
|
|
328
|
+
* current claim. Different keys never contend. Same non-rejecting-tail contract
|
|
329
|
+
* as `withStoreLock`. Keyed by pinKey, held by the gateway around the WHOLE
|
|
330
|
+
* read-prev → decide → reconcileAndPersistStatusPin → update-Maps sequence.
|
|
331
|
+
*/
|
|
332
|
+
const pinReconcileTails = new Map<string, Promise<unknown>>()
|
|
333
|
+
|
|
334
|
+
export function withPinReconcileLock<T>(
|
|
335
|
+
pinKey: string,
|
|
336
|
+
fn: () => Promise<T>,
|
|
337
|
+
): Promise<T> {
|
|
338
|
+
const prev = pinReconcileTails.get(pinKey) ?? Promise.resolve()
|
|
339
|
+
const run = prev.then(fn, fn)
|
|
340
|
+
pinReconcileTails.set(
|
|
341
|
+
pinKey,
|
|
342
|
+
run.then(
|
|
343
|
+
() => undefined,
|
|
344
|
+
() => undefined,
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
return run
|
|
348
|
+
}
|
|
349
|
+
|
|
316
350
|
/**
|
|
317
351
|
* READ-MODIFY-WRITE for exactly ONE pinKey's row, against the authoritative
|
|
318
352
|
* on-disk file. Loads the current snapshot from disk, drops any row for
|
|
@@ -431,11 +465,37 @@ export function reconcileAndPersistStatusPin(args: {
|
|
|
431
465
|
return next
|
|
432
466
|
}
|
|
433
467
|
|
|
434
|
-
// clear: unpin (best-effort) THEN
|
|
435
|
-
//
|
|
436
|
-
//
|
|
468
|
+
// clear: unpin (best-effort) THEN reconcile the record with the OUTCOME.
|
|
469
|
+
//
|
|
470
|
+
// F1 (invisible-worker-cards review): a `clear` op covers BOTH a genuine
|
|
471
|
+
// unpin AND a `noop: already pinned` — the pin decision maps every non-`pin`
|
|
472
|
+
// action here (see decidePinAction → the gateway's op mapping). For a real
|
|
473
|
+
// unpin, applyPin drops the claim and returns null → we remove the row. But
|
|
474
|
+
// for a noop-already-pinned, reconcilePin returns the LIVE claim unchanged
|
|
475
|
+
// (non-null) and issues NO Telegram call — the pin is still up. The worker
|
|
476
|
+
// feed calls syncPin on EVERY steady-state edit, so a noop-clear fires
|
|
477
|
+
// constantly; unconditionally deleting the row there erased the durable
|
|
478
|
+
// status-pins.json entry for a still-live pin. A crash after that left a
|
|
479
|
+
// stuck pinned card boot cleanup could never see. So: only drop the row when
|
|
480
|
+
// the claim is actually gone (next == null); when applyPin returns a live
|
|
481
|
+
// claim, PRESERVE the row (rewritten confirmed) so the durable record keeps
|
|
482
|
+
// tracking the pin that is genuinely still up. No extra Telegram API call is
|
|
483
|
+
// added — applyPin already ran; this only changes the disk write's content.
|
|
484
|
+
// Ordering for the real-unpin case is safe: if we crash after the unpin but
|
|
485
|
+
// before the rewrite, the stale record just gets unpinned again next boot
|
|
486
|
+
// (idempotent), never a lingering pin.
|
|
437
487
|
const next = await args.applyPin()
|
|
438
|
-
|
|
488
|
+
if (next == null) {
|
|
489
|
+
applyStatusPinRow(path, fs, pinKey, null, log)
|
|
490
|
+
} else {
|
|
491
|
+
applyStatusPinRow(
|
|
492
|
+
path,
|
|
493
|
+
fs,
|
|
494
|
+
pinKey,
|
|
495
|
+
{ pinKey, chatId, messageId: next.messageId },
|
|
496
|
+
log,
|
|
497
|
+
)
|
|
498
|
+
}
|
|
439
499
|
return next
|
|
440
500
|
})
|
|
441
501
|
}
|
|
@@ -307,6 +307,9 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
307
307
|
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
|
|
308
308
|
// latch, reset at turn start alongside the other answer flags.
|
|
309
309
|
answerDelivered: false,
|
|
310
|
+
// #3429 — flushed-answer text for the content-vs-flush latch
|
|
311
|
+
// discrimination; stamped at flush arm, reset at turn start.
|
|
312
|
+
flushedAnswerText: null,
|
|
310
313
|
// 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
|
|
311
314
|
endedAt: null,
|
|
312
315
|
firstPingAt: null,
|
|
@@ -535,7 +538,10 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
535
538
|
if (turn != null) {
|
|
536
539
|
turn.currentModel = ev.model
|
|
537
540
|
}
|
|
538
|
-
|
|
541
|
+
// `replayed` (#3427 H2): a first-attach replay line carries the
|
|
542
|
+
// PRE-restart session's model — record it for freshness (unchanged
|
|
543
|
+
// behavior) but exclude it from divergence verification.
|
|
544
|
+
sessionModelSource.noteTranscriptModel(ev.model, { replayed: ev.replayed === true })
|
|
539
545
|
return
|
|
540
546
|
}
|
|
541
547
|
case 'usage': {
|
|
@@ -1623,16 +1629,25 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1623
1629
|
// corrects it in place.
|
|
1624
1630
|
//
|
|
1625
1631
|
// TWO distinct arbiters set synchronously here, before any `await`:
|
|
1626
|
-
// (a) `turn.answerDelivered` — the backstop-vs-LATE-REPLY
|
|
1627
|
-
// reply path already reads
|
|
1628
|
-
// `
|
|
1632
|
+
// (a) `turn.answerDelivered = 'flush'` — the backstop-vs-LATE-REPLY
|
|
1633
|
+
// signal the reply path already reads
|
|
1634
|
+
// (`decideAnswerLatchSuppression` + `flushedTurnSupersede`).
|
|
1635
|
+
// Source-tagged 'flush' (#3426): the late-reply suppression is
|
|
1636
|
+
// scoped to flush-armed latches, so a later async handback
|
|
1637
|
+
// attributed to a reply-delivered ended turn is never dropped.
|
|
1629
1638
|
// (b) `backstopDeliveryLedger.claim` — the backstop-vs-BACKSTOP
|
|
1630
1639
|
// double-fire latch: `claim` returning false means this turn
|
|
1631
1640
|
// already fired a backstop (answer-ready quiescence, then the
|
|
1632
1641
|
// turn-end backstop), so this fire is a no-op. It does NOT
|
|
1633
1642
|
// arbitrate the late reply (that is (a)); it is redundant-but-
|
|
1634
1643
|
// cheap with the `currentTurn == null` bail below.
|
|
1635
|
-
turn.answerDelivered =
|
|
1644
|
+
turn.answerDelivered = 'flush'
|
|
1645
|
+
// #3429 — stamp WHAT the flush is delivering alongside the arm, so the
|
|
1646
|
+
// late-reply suppression can discriminate by content: a late reply
|
|
1647
|
+
// carrying this same answer is the flush race (suppress/supersede); a
|
|
1648
|
+
// late reply carrying DIFFERENT content is a genuinely new async
|
|
1649
|
+
// handback attributed to this ended turn and must send fresh.
|
|
1650
|
+
turn.flushedAnswerText = capturedText
|
|
1636
1651
|
const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId)
|
|
1637
1652
|
|
|
1638
1653
|
// #654 deterministic double-message fix. Hand off the pinned
|
|
@@ -1830,6 +1845,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1830
1845
|
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
1831
1846
|
backstopDeliveryLedger.release(turn.turnId)
|
|
1832
1847
|
turn.answerDelivered = false
|
|
1848
|
+
turn.flushedAnswerText = null // #3429 — cleared with the latch
|
|
1833
1849
|
} else if (backstopCtrl) {
|
|
1834
1850
|
backstopCtrl.finalize('done')
|
|
1835
1851
|
}
|
|
@@ -1854,6 +1870,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1854
1870
|
process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${(err as Error).message}\n`)
|
|
1855
1871
|
if (!delivered) {
|
|
1856
1872
|
turn.answerDelivered = false
|
|
1873
|
+
turn.flushedAnswerText = null // #3429 — cleared with the latch
|
|
1857
1874
|
backstopDeliveryLedger.release(turn.turnId)
|
|
1858
1875
|
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
1859
1876
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adversarial-review F4 — account-label masking policy for the `/usage` card.
|
|
3
|
+
*
|
|
4
|
+
* A group configured with an EMPTY `allowFrom` authorizes every member
|
|
5
|
+
* (`isAuthorizedSender` returns true for any sender in that group). That is an
|
|
6
|
+
* intentional "whole-group" access mode, but it means `/usage` would expose
|
|
7
|
+
* per-account email labels + quota headroom to every member of a broadly
|
|
8
|
+
* shared group. This predicate decides whether to mask account labels for the
|
|
9
|
+
* quota-bearing card, reusing the existing demo-mask machinery:
|
|
10
|
+
*
|
|
11
|
+
* - private (operator DM) chat → never mask
|
|
12
|
+
* - group / supergroup with a NON-empty pinned `allowFrom` → trusted,
|
|
13
|
+
* operator-curated member list → don't mask
|
|
14
|
+
* - group / supergroup with an EMPTY `allowFrom` (open membership) → mask
|
|
15
|
+
* - any other chat type reaching a quota render → mask defensively
|
|
16
|
+
*
|
|
17
|
+
* This changes only what `/usage` REVEALS, not who may run it — general
|
|
18
|
+
* command authorization semantics are untouched.
|
|
19
|
+
*/
|
|
20
|
+
export function shouldMaskUsageLabels(
|
|
21
|
+
chatType: string | undefined,
|
|
22
|
+
groupAllowFrom: readonly string[] | undefined,
|
|
23
|
+
): boolean {
|
|
24
|
+
if (chatType === 'private') return false
|
|
25
|
+
if (chatType === 'group' || chatType === 'supergroup') {
|
|
26
|
+
return (groupAllowFrom ?? []).length === 0
|
|
27
|
+
}
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
@@ -360,6 +360,22 @@ function main() {
|
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
const input = event.tool_input ?? {}
|
|
363
|
+
// F3 (progress-card fork model): a FORK dispatch (`subagent_type === 'fork'`)
|
|
364
|
+
// inherits the PARENT session's model and IGNORES any `tool_input.model`
|
|
365
|
+
// override. Seeding the row's first-paint model from that ignored override
|
|
366
|
+
// makes the worker card show a WRONG model (e.g. "sonnet" while the fork
|
|
367
|
+
// actually runs Opus) until the watcher overwrites it from the fork's own
|
|
368
|
+
// transcript. Suppress the dispatch-time seed for forks — leave model NULL so
|
|
369
|
+
// the card omits the model rather than showing a value the fork won't honor;
|
|
370
|
+
// the transcript-sourced model then paints as soon as the first assistant
|
|
371
|
+
// line lands (transcript wins, exactly as for non-fork workers). Fixing it at
|
|
372
|
+
// the seed source (not downstream in worker-feed-dispatch) keeps the
|
|
373
|
+
// transcript-confirmed model flowing to the terminal card unchanged.
|
|
374
|
+
const isFork = input.subagent_type === 'fork'
|
|
375
|
+
const dispatchModel =
|
|
376
|
+
!isFork && typeof input.model === 'string' && input.model.length > 0
|
|
377
|
+
? input.model
|
|
378
|
+
: null
|
|
363
379
|
// Resolve parent_turn_key from the live turn-active marker (the turn whose
|
|
364
380
|
// tool call is dispatching this sub-agent). Claude Code's PreToolUse payload
|
|
365
381
|
// carries only its own session id, never the gateway-minted Telegram turn_key
|
|
@@ -384,8 +400,9 @@ function main() {
|
|
|
384
400
|
// BEFORE the sub-agent writes its first assistant line. Persisted so the
|
|
385
401
|
// card can render the model from dispatch; the watcher later overwrites it
|
|
386
402
|
// from the worker's own transcript (transcript wins). Only a non-empty
|
|
387
|
-
// string is stored — never guess from config.
|
|
388
|
-
|
|
403
|
+
// string is stored — never guess from config. NULL for a fork dispatch,
|
|
404
|
+
// which ignores the model override (see dispatchModel above, F3).
|
|
405
|
+
model: dispatchModel,
|
|
389
406
|
now: Date.now(),
|
|
390
407
|
},
|
|
391
408
|
(err) => {
|
|
@@ -42,6 +42,7 @@ import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
|
|
|
42
42
|
import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
|
|
43
43
|
import { escapeMarkdown } from './card-format.js';
|
|
44
44
|
import { maskEmail } from './demo-mask.js';
|
|
45
|
+
import { formatExternalSpendBlock } from './external-spend.js';
|
|
45
46
|
|
|
46
47
|
// ── dot thresholds ───────────────────────────────────────────────────
|
|
47
48
|
|
|
@@ -146,18 +147,41 @@ export function buildBar(pct: number, elapsedFrac: number): string {
|
|
|
146
147
|
|
|
147
148
|
// ── account status (title-line suffix) ───────────────────────────────
|
|
148
149
|
|
|
149
|
-
export type QuotaBarAccountStatus =
|
|
150
|
+
export type QuotaBarAccountStatus =
|
|
151
|
+
| 'active'
|
|
152
|
+
| 'org-disabled'
|
|
153
|
+
| 'retired'
|
|
154
|
+
| 'exhausted'
|
|
155
|
+
| 'idle';
|
|
156
|
+
|
|
157
|
+
/** Title-line display word for each status (some differ from the enum key). */
|
|
158
|
+
const STATUS_LABEL: Record<QuotaBarAccountStatus, string> = {
|
|
159
|
+
active: 'active',
|
|
160
|
+
'org-disabled': 'DISABLED (org)',
|
|
161
|
+
retired: 'retired',
|
|
162
|
+
exhausted: 'exhausted',
|
|
163
|
+
idle: 'idle',
|
|
164
|
+
};
|
|
150
165
|
|
|
151
166
|
/**
|
|
152
|
-
* Title-line status word. `active`
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
167
|
+
* Title-line status word. Precedence: `active` > `DISABLED (org)` > `retired` >
|
|
168
|
+
* `exhausted` > `idle`.
|
|
169
|
+
* - `active` wins over everything (the fleet's pinned account is reported
|
|
170
|
+
* active even if also flagged exhausted — the operator wants WHICH account
|
|
171
|
+
* is live first, health second, from the window rows underneath).
|
|
172
|
+
* - `org-disabled` (entitlement block, PR2) and `retired` (`in_service` false:
|
|
173
|
+
* removed from every config list) are OUT OF SERVICE — never "idle", which
|
|
174
|
+
* reads as an available-but-unused account.
|
|
175
|
+
* - else `exhausted` if the broker flags it, else `idle`.
|
|
158
176
|
*/
|
|
159
|
-
export function accountStatus(
|
|
177
|
+
export function accountStatus(
|
|
178
|
+
isActive: boolean,
|
|
179
|
+
exhausted: boolean,
|
|
180
|
+
opts: { inService?: boolean; entitlementBlocked?: boolean } = {},
|
|
181
|
+
): QuotaBarAccountStatus {
|
|
160
182
|
if (isActive) return 'active';
|
|
183
|
+
if (opts.entitlementBlocked === true) return 'org-disabled';
|
|
184
|
+
if (opts.inService === false) return 'retired';
|
|
161
185
|
if (exhausted) return 'exhausted';
|
|
162
186
|
return 'idle';
|
|
163
187
|
}
|
|
@@ -187,8 +211,9 @@ export function renderQuotaBarAccount(
|
|
|
187
211
|
quota: QuotaUtilization | null,
|
|
188
212
|
now: Date = new Date(),
|
|
189
213
|
demo = false,
|
|
214
|
+
service: { inService?: boolean; entitlementBlocked?: boolean } = {},
|
|
190
215
|
): string[] {
|
|
191
|
-
const status = accountStatus(isActive, exhausted);
|
|
216
|
+
const status = accountStatus(isActive, exhausted, service);
|
|
192
217
|
// Title line wraps `label` in GFM `**bold**`, NOT a code span — so this
|
|
193
218
|
// needs `escapeMarkdown` (backslash-escapes *, _, [, ], etc.), not
|
|
194
219
|
// `codeSpanSafe` (which only defuses backticks and is only correct
|
|
@@ -196,7 +221,18 @@ export function renderQuotaBarAccount(
|
|
|
196
221
|
// was a bug: a label containing e.g. `**` or `[x](url)` would break the
|
|
197
222
|
// bold run or inject a markdown link into the card.
|
|
198
223
|
const displayLabel = demo ? maskEmail(label) : label;
|
|
199
|
-
const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${status})`];
|
|
224
|
+
const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${STATUS_LABEL[status]})`];
|
|
225
|
+
// Out-of-service accounts (retired / org-disabled) have no meaningful live
|
|
226
|
+
// windows — a 0%/0% bar would read as "available", the exact bug this fixes.
|
|
227
|
+
// Replace the two window rows with a single status note (shape stays a list).
|
|
228
|
+
if (status === 'retired' || status === 'org-disabled') {
|
|
229
|
+
const note =
|
|
230
|
+
status === 'org-disabled'
|
|
231
|
+
? 'disabled by org — no fleet routing'
|
|
232
|
+
: 'retired — removed from fleet rotation';
|
|
233
|
+
lines.push(`- ⚫ \`${note}\``);
|
|
234
|
+
return lines;
|
|
235
|
+
}
|
|
200
236
|
if (!quota || isProbeThin(quota)) {
|
|
201
237
|
// Data-quality gap. A failed / thin probe carries NO real utilization
|
|
202
238
|
// signal, so it must NOT render as a healthy 🟢 0% bar — that's
|
|
@@ -257,6 +293,17 @@ export interface UsageCardRenderOpts extends QuotaBarRenderOpts {
|
|
|
257
293
|
* because in that case there IS real data, just stale.
|
|
258
294
|
*/
|
|
259
295
|
probeFailed?: boolean;
|
|
296
|
+
/**
|
|
297
|
+
* Optional External (OpenRouter / non-Claude cash) spend block — layout B
|
|
298
|
+
* (operator-locked 2026-07-19). When null/undefined the block is omitted
|
|
299
|
+
* entirely (no error rows). When present (including $0.00), bullets land
|
|
300
|
+
* after the recommendation and before the freshness footer.
|
|
301
|
+
*/
|
|
302
|
+
externalSpend?: {
|
|
303
|
+
day24hUsd: number;
|
|
304
|
+
day7dUsd: number;
|
|
305
|
+
top: Array<{ label: string; usd: number }>;
|
|
306
|
+
} | null;
|
|
260
307
|
}
|
|
261
308
|
|
|
262
309
|
/**
|
|
@@ -276,10 +323,21 @@ export function renderQuotaBarBlock(
|
|
|
276
323
|
const now = opts.now ?? new Date();
|
|
277
324
|
const demo = opts.demo ?? false;
|
|
278
325
|
const lines: string[] = [];
|
|
279
|
-
|
|
326
|
+
// Out-of-service accounts (retired / org-disabled) sort LAST — stable
|
|
327
|
+
// partition preserves the caller's active-first ordering among the rest.
|
|
328
|
+
const outOfService = (s: AccountSnapshot) =>
|
|
329
|
+
!s.isActive && (s.entitlementBlocked === true || s.inService === false);
|
|
330
|
+
const ordered = [
|
|
331
|
+
...snapshots.filter((s) => !outOfService(s)),
|
|
332
|
+
...snapshots.filter(outOfService),
|
|
333
|
+
];
|
|
334
|
+
for (const snap of ordered) {
|
|
280
335
|
const exhausted = exhaustedByLabel.get(snap.label) ?? false;
|
|
281
336
|
lines.push(
|
|
282
|
-
...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo
|
|
337
|
+
...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo, {
|
|
338
|
+
inService: snap.inService,
|
|
339
|
+
entitlementBlocked: snap.entitlementBlocked,
|
|
340
|
+
}),
|
|
283
341
|
);
|
|
284
342
|
}
|
|
285
343
|
return lines.join('\n');
|
|
@@ -307,6 +365,8 @@ export function renderQuotaBarBlockFromListState(
|
|
|
307
365
|
quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
|
|
308
366
|
expiresAtMs: acc.expiresAt,
|
|
309
367
|
capturedAtMs: acc.last_quota?.capturedAt,
|
|
368
|
+
inService: acc.in_service,
|
|
369
|
+
entitlementBlocked: acc.entitlement_blocked,
|
|
310
370
|
}));
|
|
311
371
|
return renderQuotaBarBlock(snapshots, exhaustedByLabel, { now });
|
|
312
372
|
}
|
|
@@ -342,6 +402,12 @@ export function renderUsageCard(
|
|
|
342
402
|
const lines = [bar];
|
|
343
403
|
// Actionable cross-account verdict — restored from renderAuthSnapshotFormat2.
|
|
344
404
|
lines.push(`_${recommendation(snapshots, now, demo)}_`);
|
|
405
|
+
// External cash spend (OpenRouter / non-Claude) — layout B. Omitted when
|
|
406
|
+
// the caller could not fetch a summary (null/undefined: no admin key,
|
|
407
|
+
// timeout, error). Present summary including $0.00 still renders.
|
|
408
|
+
if (opts.externalSpend != null) {
|
|
409
|
+
lines.push(...formatExternalSpendBlock(opts.externalSpend));
|
|
410
|
+
}
|
|
345
411
|
// Freshness signal: stale-cache warning takes precedence over a live stamp,
|
|
346
412
|
// which takes precedence over an explicit probe-failed marker (no live data
|
|
347
413
|
// AND no cache — the card is showing "⚠️ no data" rows, so "Live" would be
|
|
@@ -252,11 +252,26 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
|
|
|
252
252
|
const lines: string[] = [];
|
|
253
253
|
lines.push("**Claude plan quota**");
|
|
254
254
|
lines.push("");
|
|
255
|
+
// #2494 Bug C / adversarial-review F3 — a window whose utilization header
|
|
256
|
+
// was absent (a thin probe) has a numeric field that coalesced to 0, so a
|
|
257
|
+
// naive `${pct}%` renders a confident `0%` indistinguishable from a genuine
|
|
258
|
+
// fresh-account 0%. The modern bar card (quota-bar-format.ts ~200) already
|
|
259
|
+
// renders these as "no data"; backport that honesty here. The presence
|
|
260
|
+
// markers are optional — `undefined` means a legacy/real probe (render the
|
|
261
|
+
// percent); only an explicit `false` suppresses the number.
|
|
262
|
+
const fiveHour =
|
|
263
|
+
q.fiveHourUtilPresent === false
|
|
264
|
+
? "no data"
|
|
265
|
+
: `\`${Math.round(q.fiveHourUtilizationPct)}%\``;
|
|
266
|
+
const sevenDay =
|
|
267
|
+
q.sevenDayUtilPresent === false
|
|
268
|
+
? "no data"
|
|
269
|
+
: `\`${Math.round(q.sevenDayUtilizationPct)}%\``;
|
|
255
270
|
lines.push(
|
|
256
|
-
`**5h window**
|
|
271
|
+
`**5h window** ${fiveHour} · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
|
|
257
272
|
);
|
|
258
273
|
lines.push(
|
|
259
|
-
`**7d window**
|
|
274
|
+
`**7d window** ${sevenDay} · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
|
|
260
275
|
);
|
|
261
276
|
if (q.representativeClaim) {
|
|
262
277
|
lines.push("");
|
|
@@ -107,6 +107,42 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
|
|
|
107
107
|
)
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The answer-delivered latch value — SOURCE-TAGGED (#3426).
|
|
112
|
+
*
|
|
113
|
+
* `false` — no answer delivered this turn (latch unarmed).
|
|
114
|
+
* `'flush'` — the TURN-FLUSH backstop delivered (or is mid-delivering) this
|
|
115
|
+
* turn's answer as its own message A. Set synchronously at
|
|
116
|
+
* flush-fire time, and at supersede-record consumption (the
|
|
117
|
+
* resurrection window — a flush record existed for this turn).
|
|
118
|
+
* `'reply'` — a normally-delivered `reply` tool call carried this turn's
|
|
119
|
+
* answer (no flush involved).
|
|
120
|
+
*
|
|
121
|
+
* Why the tag exists: the late-reply suppression below is a race backstop for
|
|
122
|
+
* FLUSH duplicates only. A boolean latch also suppressed the async sub-agent
|
|
123
|
+
* handback pattern (#3426): the parent turn's interim ack (a substantive
|
|
124
|
+
* `reply`) armed the latch, the turn ended, and the sub-agent completion
|
|
125
|
+
* handback — a genuinely NEW answer arriving with no live gateway turn —
|
|
126
|
+
* resolved the ended turn as owner (latest-ended tier, inside the 60 s
|
|
127
|
+
* supersede TTL), saw the stale latch, and was silently dropped with a false
|
|
128
|
+
* "deduped" success. Tagging the source lets the suppression fire ONLY for the
|
|
129
|
+
* flush races it exists for; byte-identical replays of a reply-delivered
|
|
130
|
+
* answer remain covered by the content-keyed outbound dedup (#546).
|
|
131
|
+
*
|
|
132
|
+
* Honest bound on that dedup cover: the #546 TTL (60 s) is anchored at reply
|
|
133
|
+
* RECORD time, while the latest-ended owner tier's 60 s is anchored at the
|
|
134
|
+
* turn's `endedAt` — later by the reply→turn_end gap. A byte-identical replay
|
|
135
|
+
* landing >60 s after record but ≤60 s after endedAt is evicted from dedup yet
|
|
136
|
+
* still resolves the ended turn, so it DELIVERS as a duplicate message. That
|
|
137
|
+
* is a conscious trade: this fix also drops the weak "reworded/bridge-replayed
|
|
138
|
+
* duplicate" suppression the reply-armed boolean latch used to provide —
|
|
139
|
+
* replays of an un-acked tool_call are byte-identical (content dedup's case),
|
|
140
|
+
* and a model-REGENERATED paraphrase is indistinguishable from a genuinely new
|
|
141
|
+
* handback, so delivering it is the correct default. A rare duplicate message
|
|
142
|
+
* beats the silent handback drop.
|
|
143
|
+
*/
|
|
144
|
+
export type AnswerDeliveredLatch = false | 'flush' | 'reply'
|
|
145
|
+
|
|
110
146
|
/**
|
|
111
147
|
* The answer-delivered latch inputs (Part 2 — the race backstop).
|
|
112
148
|
*
|
|
@@ -118,11 +154,11 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
|
|
|
118
154
|
* There `flushed-turn-supersede` finds no record (nothing to delete yet) and the
|
|
119
155
|
* reply would ship message B as a duplicate of the flush's message A.
|
|
120
156
|
*
|
|
121
|
-
* The latch closes that window: the gateway sets `answerDelivered =
|
|
122
|
-
* turn atom SYNCHRONOUSLY at flush-fire time — before the ~500 ms async send
|
|
123
|
-
* before the record — and the flag persists on the ended turn (readable via
|
|
124
|
-
* unified resolver after `currentTurn` is null). A reply landing in the race
|
|
125
|
-
* window then sees the latch already set and suppresses itself.
|
|
157
|
+
* The latch closes that window: the gateway sets `answerDelivered = 'flush'` on
|
|
158
|
+
* the turn atom SYNCHRONOUSLY at flush-fire time — before the ~500 ms async send
|
|
159
|
+
* and before the record — and the flag persists on the ended turn (readable via
|
|
160
|
+
* the unified resolver after `currentTurn` is null). A reply landing in the race
|
|
161
|
+
* window then sees the flush latch already set and suppresses itself.
|
|
126
162
|
*/
|
|
127
163
|
export interface AnswerLatchSuppressInput {
|
|
128
164
|
/** True when Part 1's supersede already fired for THIS reply (message A was
|
|
@@ -140,21 +176,50 @@ export interface AnswerLatchSuppressInput {
|
|
|
140
176
|
* legitimate second in-turn substantive reply (a genuine multi-message
|
|
141
177
|
* answer, live currentTurn) untouched. */
|
|
142
178
|
isLateReply: boolean
|
|
143
|
-
/** The resolved owner turn's `answerDelivered` latch. */
|
|
144
|
-
ownerAnswerDelivered:
|
|
179
|
+
/** The resolved owner turn's source-tagged `answerDelivered` latch. */
|
|
180
|
+
ownerAnswerDelivered: AnswerDeliveredLatch
|
|
181
|
+
/** #3429 — content evidence: does the landing reply carry the SAME answer
|
|
182
|
+
* the flush delivered (`flushedAnswerMatchesReply` against the supersede
|
|
183
|
+
* record's text or the owner turn's stashed `flushedAnswerText`)?
|
|
184
|
+
* - `false` → POSITIVE evidence of genuinely new content (an async
|
|
185
|
+
* handback attributed to the flush-delivered ended turn).
|
|
186
|
+
* Suppressing it would silently drop the user's answer —
|
|
187
|
+
* never suppress.
|
|
188
|
+
* - `true` → the reply is the flushed answer landing again — suppress
|
|
189
|
+
* (the flush race the latch exists for).
|
|
190
|
+
* - `null` / omitted → no flushed text available to compare (legacy atom,
|
|
191
|
+
* pre-#3429 caller). Conservative: keep the pre-#3429
|
|
192
|
+
* flush-armed suppression. */
|
|
193
|
+
replyMatchesFlushedAnswer?: boolean | null
|
|
145
194
|
}
|
|
146
195
|
|
|
147
196
|
/**
|
|
148
197
|
* Decide whether the answer-delivered latch suppresses a landing reply.
|
|
149
198
|
*
|
|
150
199
|
* Suppress IFF: Part 1 did NOT already supersede, the reply is a substantive
|
|
151
|
-
* final answer, it is a late reply (no live turn), AND the owner turn's latch
|
|
152
|
-
*
|
|
153
|
-
* the pre-record race window
|
|
200
|
+
* final answer, it is a late reply (no live turn), AND the owner turn's latch
|
|
201
|
+
* was armed by the TURN-FLUSH path (`'flush'` — the flush delivered the same
|
|
202
|
+
* substantive answer as message A in the pre-record race window, or the
|
|
203
|
+
* supersede-consumed resurrection window). Otherwise the reply sends.
|
|
204
|
+
*
|
|
205
|
+
* A `'reply'`-armed latch deliberately does NOT suppress (#3426): the prior
|
|
206
|
+
* answer went out via a normal, completed `reply`, so a later late-landing
|
|
207
|
+
* reply attributed to that ended turn is NOT a flush duplicate — it is
|
|
208
|
+
* (typically) an async sub-agent handback carrying genuinely new content, and
|
|
209
|
+
* suppressing it silently drops the user's answer. Byte-identical replays of
|
|
210
|
+
* the delivered reply are still deduped by the content-keyed #546 cache.
|
|
211
|
+
*
|
|
212
|
+
* #3429 refinement: even a FLUSH-armed latch does not suppress when there is
|
|
213
|
+
* POSITIVE content evidence (`replyMatchesFlushedAnswer === false`) that the
|
|
214
|
+
* landing reply is NOT the flushed answer — an async handback attributed to a
|
|
215
|
+
* flush-delivered ended turn must send fresh, not vanish. Absent evidence
|
|
216
|
+
* (`null`/omitted) the flush-armed suppression holds, preserving the #2996
|
|
217
|
+
* Part 2 race backstop.
|
|
154
218
|
*/
|
|
155
219
|
export function decideAnswerLatchSuppression(input: AnswerLatchSuppressInput): boolean {
|
|
156
220
|
if (input.superseded) return false
|
|
157
221
|
if (!input.replySubstantive) return false
|
|
158
222
|
if (!input.isLateReply) return false
|
|
159
|
-
|
|
223
|
+
if (input.replyMatchesFlushedAnswer === false) return false
|
|
224
|
+
return input.ownerAnswerDelivered === 'flush'
|
|
160
225
|
}
|
|
@@ -100,7 +100,12 @@ export type SessionEvent =
|
|
|
100
100
|
// same batch already reflects the current model. Sentinels (`<synthetic>` on
|
|
101
101
|
// compaction lines, fixture junk) are filtered at projection — see
|
|
102
102
|
// isModelSentinel — so this only ever carries a real resolved model id.
|
|
103
|
-
|
|
103
|
+
// `replayed: true` marks a model observation delivered by the FIRST-ATTACH
|
|
104
|
+
// replay of a prior session's in-flight turn (computeFirstAttachCursor) —
|
|
105
|
+
// it reflects the PRE-restart session's model, not the live one. Consumers
|
|
106
|
+
// that verify the live model (#3427 divergence tripwire, H2) must skip
|
|
107
|
+
// replayed observations; freshness consumers may still record them.
|
|
108
|
+
| { kind: 'model'; model: string; replayed?: boolean }
|
|
104
109
|
| { kind: 'tool_use'; toolName: string; toolUseId?: string | null; input?: Record<string, unknown>; precomputedLabel?: string }
|
|
105
110
|
// Real-time tool label from the PreToolUse-hook sidecar — fires when the
|
|
106
111
|
// hook writes the label (synchronous at tool-call time), independent of
|
|
@@ -1217,6 +1222,17 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1217
1222
|
// re-attach.
|
|
1218
1223
|
const fileCursors = new Map<string, { cursor: number; pendingPartial: string }>()
|
|
1219
1224
|
|
|
1225
|
+
// First-attach REPLAY window per file (#3427 H2): when attachToFile replays
|
|
1226
|
+
// a prior session's in-flight turn (computeFirstAttachCursor returned an
|
|
1227
|
+
// offset below the size-at-attach), every byte below that size is HISTORY
|
|
1228
|
+
// written by the pre-restart session. Model observations projected from it
|
|
1229
|
+
// must be marked `replayed` so the divergence tripwire ignores them.
|
|
1230
|
+
// Granularity is the read CHUNK (a batch whose read started inside the
|
|
1231
|
+
// window is marked wholesale) — deliberately conservative: over-marking a
|
|
1232
|
+
// boundary batch can only delay verification to the next live line, never
|
|
1233
|
+
// false-accuse.
|
|
1234
|
+
const replayUntilByFile = new Map<string, number>()
|
|
1235
|
+
|
|
1220
1236
|
function readNew(): void {
|
|
1221
1237
|
if (stopped || !currentFile) return
|
|
1222
1238
|
try {
|
|
@@ -1226,9 +1242,14 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1226
1242
|
// stored per-file state for this path.
|
|
1227
1243
|
cursor = 0
|
|
1228
1244
|
pendingPartial = ''
|
|
1229
|
-
if (currentFile != null)
|
|
1245
|
+
if (currentFile != null) {
|
|
1246
|
+
fileCursors.delete(currentFile)
|
|
1247
|
+
replayUntilByFile.delete(currentFile)
|
|
1248
|
+
}
|
|
1230
1249
|
}
|
|
1231
1250
|
if (stat.size === cursor) return
|
|
1251
|
+
const chunkStart = cursor
|
|
1252
|
+
const isReplayChunk = chunkStart < (replayUntilByFile.get(currentFile) ?? 0)
|
|
1232
1253
|
const buf = Buffer.alloc(stat.size - cursor)
|
|
1233
1254
|
const fd = openSync(currentFile, 'r')
|
|
1234
1255
|
try {
|
|
@@ -1247,7 +1268,7 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1247
1268
|
const sid = sessionIdForFile(currentFile)
|
|
1248
1269
|
for (const ev of events) {
|
|
1249
1270
|
try {
|
|
1250
|
-
onEvent(decorate(ev, sid))
|
|
1271
|
+
onEvent(decorate(isReplayChunk && ev.kind === 'model' ? { ...ev, replayed: true } : ev, sid))
|
|
1251
1272
|
} catch (err) {
|
|
1252
1273
|
log?.(`session-tail: onEvent threw: ${(err as Error).message}`)
|
|
1253
1274
|
}
|
|
@@ -1322,6 +1343,9 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1322
1343
|
const size = statSync(file).size
|
|
1323
1344
|
cursor = computeFirstAttachCursor(file, size)
|
|
1324
1345
|
if (cursor < size) {
|
|
1346
|
+
// #3427 H2: everything below size-at-attach is pre-restart history;
|
|
1347
|
+
// model events projected from it are marked `replayed` in readNew.
|
|
1348
|
+
replayUntilByFile.set(file, size)
|
|
1325
1349
|
log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`)
|
|
1326
1350
|
} else {
|
|
1327
1351
|
log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
|
|
@@ -58,6 +58,53 @@ describe('activity-card durability wiring', () => {
|
|
|
58
58
|
expect(recordCode).not.toMatch(/pinned: true/)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
+
// F6 (persist-intent-first ordering): the durable record must be written in
|
|
62
|
+
// the SAME synchronous block as the send, BEFORE the status-pin reconcile and
|
|
63
|
+
// with NO `await` between the send resolving and the persist — otherwise the
|
|
64
|
+
// crash window in which a sent card has no reapable record reopens. Structural
|
|
65
|
+
// lock (the gateway IIFE can't be instantiated in-process); complements the
|
|
66
|
+
// behavioural store/reaper tests in activity-card-store.test.ts.
|
|
67
|
+
it('(F6) persists the record BEFORE the status-pin reconcile, with no await between send and persist', () => {
|
|
68
|
+
const openBranch = between(
|
|
69
|
+
laneSrc,
|
|
70
|
+
'if (turn.activityMessageId == null) {',
|
|
71
|
+
'turn.activityLastSentRender = target',
|
|
72
|
+
)
|
|
73
|
+
const persistIdx = openBranch.indexOf('writeActivityCardRecord(')
|
|
74
|
+
const pinIdx = openBranch.indexOf('void reconcileStatusPin(')
|
|
75
|
+
expect(persistIdx).toBeGreaterThanOrEqual(0)
|
|
76
|
+
expect(pinIdx).toBeGreaterThanOrEqual(0)
|
|
77
|
+
// Persist-intent-first: the record write precedes the pin reconcile.
|
|
78
|
+
expect(persistIdx).toBeLessThan(pinIdx)
|
|
79
|
+
// No `await` sits between the send returning and the persist — the whole
|
|
80
|
+
// window from `const sent =` to writeActivityCardRecord( is synchronous.
|
|
81
|
+
const sendToPersist = between(openBranch, 'const sent = await robustApiCall', 'writeActivityCardRecord(')
|
|
82
|
+
const codeOnly = sendToPersist
|
|
83
|
+
.split('\n')
|
|
84
|
+
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*'))
|
|
85
|
+
.join('\n')
|
|
86
|
+
expect(codeOnly).not.toMatch(/\bawait\b/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// F5 (persist-intent honesty): the persisted `pinned` mirrors the ACTUAL pin
|
|
90
|
+
// decision (PIN_STATUS_WHILE_WORKING), never a bare `pinned: true`. This is
|
|
91
|
+
// also asserted in the OPEN test above; kept as a named F5 lock so a rename or
|
|
92
|
+
// refactor that reintroduces an unconditional `pinned: true` is caught here too.
|
|
93
|
+
it('(F5) the persisted record mirrors the real pin decision, not an unconditional pinned:true', () => {
|
|
94
|
+
const openBranch = between(
|
|
95
|
+
laneSrc,
|
|
96
|
+
'if (turn.activityMessageId == null) {',
|
|
97
|
+
'turn.activityLastSentRender = target',
|
|
98
|
+
)
|
|
99
|
+
const recordBlock = between(openBranch, 'writeActivityCardRecord(', 'void reconcileStatusPin(')
|
|
100
|
+
const recordCode = recordBlock
|
|
101
|
+
.split('\n')
|
|
102
|
+
.filter((l) => !l.trim().startsWith('//'))
|
|
103
|
+
.join('\n')
|
|
104
|
+
expect(recordCode).toMatch(/pinned: PIN_STATUS_WHILE_WORKING/)
|
|
105
|
+
expect(recordCode).not.toMatch(/pinned: true/)
|
|
106
|
+
})
|
|
107
|
+
|
|
61
108
|
it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
|
|
62
109
|
const closeBody = between(
|
|
63
110
|
laneSrc,
|