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 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 finished (or errored) → clear the pulsing indicator AND resolve the
2286
- // "◈ compacting context…" line so it doesn't sit there forever looking stuck (Max asked
2287
- // "is it compacted?" — 2026-07-02). On success post an explicit "◈ context compacted"
2288
- // control line, paired with the start line; on error the SDK's AbortError/too-small
2289
- // text is the signal, so no line. Same control-event shape as ctlLine (kind:'control').
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
@@ -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
- const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; emitRaw(evt) }
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
- const replay = wasActive ? lastTurnText : null
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 mm = String(m).match(/(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i)
231
- return mm ? `${mm[1][0].toUpperCase()}${mm[1].slice(1)} ${mm[2]}${mm[3] ? '.' + mm[3] : ''}` : String(m)
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
- const perStr = sum.per.map((r) => {
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 = sum.per.length > 1 ? ` · total ${fmtTok(sum.totalIn)} in / ${fmtTok(sum.totalOut)} out` : ''
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.183",
3
+ "version": "0.7.184",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
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
- export function stallDecision({ turnActive, awaitingUser, quietMs, stallMs, forceStopMs, stalledSent, stallRetried }) {
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.