thinkpool-pair 0.7.183 → 0.7.184
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/bridge.mjs +23 -9
- package/claude-session.mjs +23 -4
- package/event-id.mjs +15 -4
- package/package.json +1 -1
- package/turn-stall.mjs +19 -1
package/bridge.mjs
CHANGED
|
@@ -2282,18 +2282,32 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2282
2282
|
// 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
|
|
2283
2283
|
// composer UI, broadcast to both clients but never logged/persisted/replayed.
|
|
2284
2284
|
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion'
|
|
2285
|
-
// compact turn
|
|
2286
|
-
//
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2285
|
+
// The compact turn settled with `entry.compacting` STILL TRUE — which, because the
|
|
2286
|
+
// `compaction` branch above clears the flag the moment the milestone lands, means
|
|
2287
|
+
// exactly one thing: NO compaction happened. Three ways to get here, all of which
|
|
2288
|
+
// used to end in silence (the indicator blinked out and the context was unchanged,
|
|
2289
|
+
// so "did that work?" was unanswerable — Max, 2026-07-10):
|
|
2290
|
+
// • aborted — Stop, a model switch, or (until turn-stall.mjs) the stall watchdog
|
|
2291
|
+
// killing the long silent compaction call outright.
|
|
2292
|
+
// • no-op — the SDK declines: "Not enough messages to compact."
|
|
2293
|
+
// • error — the compaction call itself failed.
|
|
2294
|
+
// `/compact` writes no `you` line (the code-turn handler returns before echoYou), so
|
|
2295
|
+
// without this the room has no record the command was ever run. Say what happened,
|
|
2296
|
+
// once, durably — same control-event shape as ctlLine.
|
|
2290
2297
|
if ((evt.kind === 'result' || evt.kind === 'error') && entry.compacting) {
|
|
2298
|
+
const by = entry.compactBy || null
|
|
2291
2299
|
entry.compacting = false
|
|
2292
|
-
// Clear the ephemeral "Compacting…" indicator. The persistent record is the
|
|
2293
|
-
// CompactionCard (kind:'compaction', now attributed) — no redundant control line
|
|
2294
|
-
// that lingers in scrollback reading as "compacted recently" (Max, 2026-07-02).
|
|
2295
|
-
bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2296
2300
|
entry.compactBy = null
|
|
2301
|
+
// Clear the ephemeral "Compacting…" indicator. On the SUCCESS path the persistent
|
|
2302
|
+
// record is the CompactionCard (kind:'compaction', attributed) — no redundant control
|
|
2303
|
+
// line that reads as "compacted recently" in scrollback (Max, 2026-07-02).
|
|
2304
|
+
bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2305
|
+
const why = evt.kind === 'error' ? 'compaction failed'
|
|
2306
|
+
: evt.subtype === 'aborted' ? 'compaction canceled'
|
|
2307
|
+
: 'nothing to compact'
|
|
2308
|
+
const ce = { kind: 'control', text: `${why} — context unchanged`, by }
|
|
2309
|
+
pushLog(entry, ce)
|
|
2310
|
+
bcast('code-event', { term: id, evt: ce })
|
|
2297
2311
|
}
|
|
2298
2312
|
// ── Slice 3: the turn settled. Two things happen, in this order.
|
|
2299
2313
|
// 1. Every outstanding permission card on this lane is now dead (the SDK
|
package/claude-session.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { sanitizeSession } from './transcript-sanitize.mjs'
|
|
|
21
21
|
import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
|
|
22
22
|
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
23
23
|
import { correctContext } from './context-windows.mjs'
|
|
24
|
-
import { stallDecision, stallEvent } from './turn-stall.mjs'
|
|
24
|
+
import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
|
|
25
25
|
|
|
26
26
|
// The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
|
|
27
27
|
// once at import by walking up from the package entry to its own package.json.
|
|
@@ -312,6 +312,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
312
312
|
const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
|
|
313
313
|
let forceStopped = false
|
|
314
314
|
let stallRetried = false // one auto-retry per turn (the abort+re-run path below)
|
|
315
|
+
// A manual /compact is one long, LEGITIMATELY silent model call — not a wedge. The
|
|
316
|
+
// watchdog stands down for its whole duration (stallDecision's `compacting` branch);
|
|
317
|
+
// only a compaction quiet for COMPACT_FORCE_STOP_MS (20 min default) is force-stopped,
|
|
318
|
+
// and never by aborting the query. Set in sendTurn, cleared on compact_boundary (the
|
|
319
|
+
// compaction landed) and on any result/error (the turn settled, incl. a no-op compact).
|
|
320
|
+
const COMPACT_FORCE_STOP_MS = Math.max(FORCE_STOP_MS, parseInt(process.env.TP_COMPACT_FORCE_STOP_MS, 10) || 1200000)
|
|
321
|
+
let compacting = false
|
|
315
322
|
// Waiting on a HUMAN decision (permission card / plan gate / AskUserQuestion)
|
|
316
323
|
// is NOT a wedge — the turn is correctly idle until the person answers. Every
|
|
317
324
|
// interactive gate goes through requestPermission, so wrap it once to bump a
|
|
@@ -360,10 +367,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
360
367
|
// the last real SDK event). emit() is the normal path: it stamps liveness + clears the
|
|
361
368
|
// one-shot stall flag on any real event other than a `stalled` re-emit.
|
|
362
369
|
const emitRaw = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
|
|
363
|
-
|
|
370
|
+
// The compaction window closes on its milestone (`compaction`) or on the turn settling
|
|
371
|
+
// (`result`/`error` — covers a no-op "Not enough messages to compact" and a Stop). After
|
|
372
|
+
// that the normal wedge timeline applies again.
|
|
373
|
+
const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; if (evt && (evt.kind === 'compaction' || evt.kind === 'result' || evt.kind === 'error')) compacting = false; emitRaw(evt) }
|
|
364
374
|
const stallTimer = setInterval(() => {
|
|
365
375
|
const quiet = Date.now() - lastEvtTs
|
|
366
|
-
const action = stallDecision({ turnActive, awaitingUser, quietMs: quiet, stallMs: STALL_MS, forceStopMs: FORCE_STOP_MS, stalledSent, stallRetried })
|
|
376
|
+
const action = stallDecision({ turnActive, awaitingUser, quietMs: quiet, stallMs: STALL_MS, forceStopMs: FORCE_STOP_MS, stalledSent, stallRetried, compacting, compactForceStopMs: COMPACT_FORCE_STOP_MS })
|
|
367
377
|
if (action === 'none') return
|
|
368
378
|
// ALWAYS surface — a stall is never swallowed. emitRaw so this synthetic event
|
|
369
379
|
// doesn't reset the silence clock (see emitRaw above).
|
|
@@ -1110,7 +1120,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
1110
1120
|
if (closed) return
|
|
1111
1121
|
stallRetried = true
|
|
1112
1122
|
const wasActive = turnActive
|
|
1113
|
-
|
|
1123
|
+
// NEVER replay a `/compact`. The abort below is what cancels an in-flight compaction, and
|
|
1124
|
+
// re-pushing the command just feeds the next abort. stallDecision already stands down while
|
|
1125
|
+
// `compacting` is armed, so this is the belt for the window where the flag was cleared by a
|
|
1126
|
+
// mid-compaction error but lastTurnText is still the command.
|
|
1127
|
+
const replay = wasActive && !isCompactTurn(lastTurnText) ? lastTurnText : null
|
|
1114
1128
|
turnActive = false // unblock between-turns updates while we tear down
|
|
1115
1129
|
const dying = qDone
|
|
1116
1130
|
const oldInput = input
|
|
@@ -1136,6 +1150,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
1136
1150
|
// is queue-backed, so the pushed turn buffers and runs once the query is ready.
|
|
1137
1151
|
sendTurn(text) { if (!closed) { if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
|
|
1138
1152
|
const t = String(text)
|
|
1153
|
+
// Arm the compaction window BEFORE the push: from here until the `compaction` milestone
|
|
1154
|
+
// (or the turn's result) the SDK is allowed to be silent for minutes without the stall
|
|
1155
|
+
// watchdog aborting it. See turn-stall.mjs — aborting a compaction is what produced
|
|
1156
|
+
// `AbortError: Compaction canceled.` in the room.
|
|
1157
|
+
compacting = isCompactTurn(t)
|
|
1139
1158
|
// SDK 0.3.198 only recognizes a slash command (/compact, /clear, custom /cmds) when the
|
|
1140
1159
|
// user message is a SINGLE text block. Appending the per-turn ROOM NOW <system-reminder>
|
|
1141
1160
|
// as a 2nd block made 0.3.198 treat /compact as a plain turn → compaction silently
|
package/event-id.mjs
CHANGED
|
@@ -225,10 +225,16 @@ export const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test
|
|
|
225
225
|
|
|
226
226
|
// model id → friendly label for the /usage report: claude-opus-4-8 → "Opus 4.8".
|
|
227
227
|
// Bridge-local twin of the web's prettyModel (structured.jsx); falls back to the raw id.
|
|
228
|
+
// The `[1m]` long-context suffix is PRESERVED. `claude-opus-4-8` and `claude-opus-4-8[1m]`
|
|
229
|
+
// are different models on different rates and summarizeUsage keys them apart — collapsing
|
|
230
|
+
// both to "Opus 4.8" printed two identical, unattributable rows in one report (real log,
|
|
231
|
+
// room 1TV7B59A: `Opus 4.8: 51.8M in … · Opus 4.8: 46.0M in …`). 2026-07-10.
|
|
228
232
|
export function prettyModelId (m) {
|
|
229
233
|
if (!m) return 'unknown'
|
|
230
|
-
const
|
|
231
|
-
|
|
234
|
+
const raw = String(m)
|
|
235
|
+
const long = /\[1m\]/i.test(raw) ? ' [1m]' : ''
|
|
236
|
+
const mm = raw.match(/(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i)
|
|
237
|
+
return mm ? `${mm[1][0].toUpperCase()}${mm[1].slice(1)} ${mm[2]}${mm[3] ? '.' + mm[3] : ''}${long}` : raw
|
|
232
238
|
}
|
|
233
239
|
|
|
234
240
|
// Build the ONE ◆ control line the /usage command emits, from the session's model +
|
|
@@ -246,11 +252,16 @@ export function usageReportLine (sessionModel, logEvents) {
|
|
|
246
252
|
if (!anyClaude && (sessionModel || sum.turns)) return 'usage reporting is available on Claude models only'
|
|
247
253
|
if (!sum.turns) return 'usage · no completed turns yet this session'
|
|
248
254
|
const fmtTok = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n)
|
|
249
|
-
|
|
255
|
+
// Drop models that burned nothing. The SDK stamps a `result` with the resolved id even for
|
|
256
|
+
// turns that spent no tokens (an interrupt, a no-op /compact), so a real report carried dead
|
|
257
|
+
// rows like `opus[1m]: 0 in / 0 out` (room 1TV7B59A). If EVERY row is empty, print them all
|
|
258
|
+
// rather than an eerie modelless line. `total` keys off the printed rows, not the raw set.
|
|
259
|
+
const rows = sum.per.filter((r) => r.in || r.out).length ? sum.per.filter((r) => r.in || r.out) : sum.per
|
|
260
|
+
const perStr = rows.map((r) => {
|
|
250
261
|
const cch = r.cached ? ` (${fmtTok(r.cached)} cached)` : ''
|
|
251
262
|
return `${prettyModelId(r.model)}: ${fmtTok(r.in)} in${cch} / ${fmtTok(r.out)} out`
|
|
252
263
|
}).join(' · ')
|
|
253
|
-
const total =
|
|
264
|
+
const total = rows.length > 1 ? ` · total ${fmtTok(sum.totalIn)} in / ${fmtTok(sum.totalOut)} out` : ''
|
|
254
265
|
const cost = sum.haveCost ? ` · ~$${sum.totalCost.toFixed(2)} est` : ''
|
|
255
266
|
const turns = `${sum.turns} turn${sum.turns === 1 ? '' : 's'}`
|
|
256
267
|
return `usage · ${turns} · ${perStr}${total}${cost} · tokens + cost only (no plan/rate-limit meters)`
|
package/package.json
CHANGED
package/turn-stall.mjs
CHANGED
|
@@ -22,13 +22,31 @@
|
|
|
22
22
|
// quietMs > stallMs (90s) → 'status' — surface a "still retrying (Ns)" banner, ONCE
|
|
23
23
|
// quietMs > forceStopMs (5min) → 'retry' — abort the hung call, re-run the turn ONCE
|
|
24
24
|
// quietMs > forceStopMs again → 'giveup' — the one retry also stalled: force-stop, resend
|
|
25
|
-
|
|
25
|
+
//
|
|
26
|
+
// COMPACTION is the third "silence is not a wedge" case, alongside idle + awaitingUser.
|
|
27
|
+
// A `/compact` is ONE model call that summarises the whole transcript and emits NOTHING until
|
|
28
|
+
// it lands: measured at 28.5s of dead air on a 54k-token context (bridge/probe-compact.mjs),
|
|
29
|
+
// and Max's real compactions carried 397k–618k pre-tokens — minutes of silence. The watchdog
|
|
30
|
+
// read that as a wedge, fired 'retry' at 300s, and retryStalledTurn's qAc.abort() KILLED the
|
|
31
|
+
// compaction: `AbortError: Compaction canceled.` landed in the shared transcript, the context
|
|
32
|
+
// was unchanged, and the retry re-pushed `/compact` to be aborted again. (Room 1TV7B59A, lane
|
|
33
|
+
// 9ad2e6bf, seq 1210-1215 on 2026-07-09 20:00 — and the same shape again at seq 2239-2241.)
|
|
34
|
+
// So while compacting: never 'status' (the "still retrying" copy is a lie, and the compact
|
|
35
|
+
// indicator already owns that UI) and never 'retry' (the abort IS the bug). A genuinely hung
|
|
36
|
+
// compaction still recovers at a much longer ceiling — 'giveup' force-stops without aborting.
|
|
37
|
+
export function stallDecision({ turnActive, awaitingUser, quietMs, stallMs, forceStopMs, stalledSent, stallRetried, compacting = false, compactForceStopMs = Infinity }) {
|
|
26
38
|
if (!turnActive || awaitingUser > 0) return 'none'
|
|
39
|
+
if (compacting) return quietMs > compactForceStopMs ? 'giveup' : 'none'
|
|
27
40
|
if (quietMs > forceStopMs) return stallRetried ? 'giveup' : 'retry'
|
|
28
41
|
if (!stalledSent && quietMs > stallMs) return 'status'
|
|
29
42
|
return 'none'
|
|
30
43
|
}
|
|
31
44
|
|
|
45
|
+
// Is this turn text a manual compaction request? The SDK recognises `/compact` (optionally
|
|
46
|
+
// with focus instructions) as a slash command when the user message is a single text block —
|
|
47
|
+
// see claude-session's sendTurn. Exported so the watchdog and the re-push guard agree.
|
|
48
|
+
export const isCompactTurn = (text) => /^\s*\/compact\b/.test(String(text || ''))
|
|
49
|
+
|
|
32
50
|
// The room-facing event for a watchdog action (pure → the exact wording is unit-tested).
|
|
33
51
|
// 'status' rides the transient `stalled` chrome event (broadcast, never persisted); 'retry'
|
|
34
52
|
// and 'giveup' ride `error` (recoverable) so the stall is ALWAYS surfaced, never swallowed.
|