thinkpool-pair 0.7.182 → 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
@@ -1900,9 +1900,10 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1900
1900
  // This opens a brand-new structured session server-side (the same path the
1901
1901
  // web's "+ New terminal" and the restore loop use), so announce() surfaces
1902
1902
  // its tab in the open web UI live. Auto-allowed by the PreToolUse gate but
1903
- // bounded by spawnDecision (hop-0-only, per-turn cap, the live terminal cap,
1904
- // TP_SPAWN_OFF kill-switch). A spawned lane runs one hop deep, so it cannot
1905
- // recursively spawn/post onward — the fork-bomb breaker (mirrors Tier C).
1903
+ // bounded by spawnDecision (depth-2 hop bound, burst window, the live terminal
1904
+ // cap, TP_SPAWN_OFF kill-switch). A person's lane (hop 0) may spawn conductors
1905
+ // (hop 1) which may spawn workers (hop 2); hop 2 can never spawn onward — the
1906
+ // fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
1906
1907
  tool(
1907
1908
  'spawn_terminal',
1908
1909
  'Open a NEW agent terminal (a fresh Claude lane) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. Use it to fan work out: give it an initial `task` and it runs in parallel in its own lane; collect its result later with read_terminal, and close it with close_terminal when done. By default the new lane INHERITS your current permission mode — so if you are in bypass, it runs autonomously with no per-action clicking; pass `mode` to override. ALWAYS prefer spawning a fresh lane over post_to_terminal into one that is already busy. Tier guidance: default worker lanes to opus; use sonnet for mechanical work (renames, boilerplate, doc sweeps); reserve your own model for planning/review. Bounded by the room terminal cap and a per-turn limit.',
@@ -1947,9 +1948,11 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1947
1948
  ne.spawnedBy = id // ownership: only the spawner may close_terminal it
1948
1949
  announce() // (defensive) ensure the web renders the new tab live
1949
1950
  if (args?.task) {
1950
- // One hop deep: the spawned lane cannot spawn/post onward until a person
1951
- // speaks to it (code-turn resets hop to 0). Mirrors the Tier C injection.
1952
- ne.hop = 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
1951
+ // One level deeper than the spawner: hop = parent.hop + 1, so the depth-2
1952
+ // bound in spawnDecision binds at every level (hop-0 person's lane → hop-1
1953
+ // conductor → hop-2 worker, which can never spawn onward). A person speaking
1954
+ // to any lane still resets it to hop 0 (code-turn). Mirrors Tier C injection.
1955
+ ne.hop = (entry.hop || 0) + 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
1953
1956
  const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool cross-terminal; you are a fresh lane it opened for you]\n${args.task}`
1954
1957
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true }
1955
1958
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
@@ -2279,18 +2282,32 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2279
2282
  // 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
2280
2283
  // composer UI, broadcast to both clients but never logged/persisted/replayed.
2281
2284
  const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion'
2282
- // compact turn finished (or errored) → clear the pulsing indicator AND resolve the
2283
- // "◈ compacting context…" line so it doesn't sit there forever looking stuck (Max asked
2284
- // "is it compacted?" — 2026-07-02). On success post an explicit "◈ context compacted"
2285
- // control line, paired with the start line; on error the SDK's AbortError/too-small
2286
- // 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.
2287
2297
  if ((evt.kind === 'result' || evt.kind === 'error') && entry.compacting) {
2298
+ const by = entry.compactBy || null
2288
2299
  entry.compacting = false
2289
- // Clear the ephemeral "Compacting…" indicator. The persistent record is the
2290
- // CompactionCard (kind:'compaction', now attributed) — no redundant control line
2291
- // that lingers in scrollback reading as "compacted recently" (Max, 2026-07-02).
2292
- bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
2293
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 })
2294
2311
  }
2295
2312
  // ── Slice 3: the turn settled. Two things happen, in this order.
2296
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).
@@ -503,7 +513,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
503
513
  // hasn't already opted into). If the spawner is already in bypass, children inherit
504
514
  // bypass with NO prompt (you opted in once); a non-bypass child gates itself per
505
515
  // action anyway, so no spawn prompt there either. Bridge-side spawnDecision still
506
- // enforces hop-0-only / per-turn cap / 8-terminal cap / TP_SPAWN_OFF regardless.
516
+ // enforces the depth-2 hop bound / burst window / machineMax / TP_SPAWN_OFF regardless.
507
517
  if (toolName === 'mcp__thinkpool__spawn_terminal') {
508
518
  const childMode = toolInput?.mode || mode // inherit this lane's mode by default
509
519
  const escalating = childMode === 'bypassPermissions' && mode !== 'bypassPermissions'
@@ -690,7 +700,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
690
700
  'RUN IT, DO NOT ASK: verify your own work before calling it done. Actually run or serve what you changed, observe that it behaves correctly, and show the evidence in the room — a screenshot of the running result, the passing test output, or the real response — rather than telling the user "this should work, go test it." If you could not verify something, say exactly what is unverified. This room follows verify-before-claiming: runtime evidence you produced, not assertion.',
691
701
  'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
692
702
  'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
693
- 'CROSS-TERMINAL SPAWN: when a person asks you to fan work out across multiple agents — research lanes, parallel sub-tasks, a swarm — open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?); do NOT dump the work into siblings that are already busy (a sibling is mid-task unless read_terminal shows it idle, and hijacking it derails their work). spawn_terminal opens a brand-new Claude lane and, if you pass `task`, hands it that task immediately; it appears as a new terminal in the room. A spawned lane INHERITS your current permission mode by default — so if you are in bypass, your lanes run autonomously with no per-action clicking; pass `mode` only to override. Raising a lane to bypass from a non-bypass lane asks the room to confirm once (everything else spawns without a prompt). Collect each lane\'s result later with read_terminal (a spawned lane cannot post back to you), and tidy up with close_terminal once you have what you need — you may only close lanes you spawned yourself. You are bounded: a few new lanes per turn, a per-plan dispatch ceiling on concurrent spawned lanes (Free 3 / Plus 6) that is separate from the human terminals so it never blocks them, an overall room machine cap, and a lane you were spawned/cross-posted into cannot itself spawn (so no runaway). This is how you "open the terminals yourself" instead of asking a person to.',
703
+ 'CROSS-TERMINAL SPAWN: when a person asks you to fan work out across multiple agents — research lanes, parallel sub-tasks, a swarm — open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?); do NOT dump the work into siblings that are already busy (a sibling is mid-task unless read_terminal shows it idle, and hijacking it derails their work). spawn_terminal opens a brand-new Claude lane and, if you pass `task`, hands it that task immediately; it appears as a new terminal in the room. A spawned lane INHERITS your current permission mode by default — so if you are in bypass, your lanes run autonomously with no per-action clicking; pass `mode` only to override. Raising a lane to bypass from a non-bypass lane asks the room to confirm once (everything else spawns without a prompt). Collect each lane\'s result later with read_terminal (a spawned lane cannot post back to you), and tidy up with close_terminal once you have what you need — you may only close lanes you spawned yourself. You are bounded: a sliding spawn-burst window, a per-plan dispatch ceiling on concurrent spawned lanes (Free 3 / Plus 6) that is separate from the human terminals so it never blocks them, an overall machine cap on live lanes, and a DEPTH bound — a lane opened by a person may spawn workers, and those workers may spawn one further level (a conductor dispatching its crew), but that second level can never spawn onward (so no runaway). This is how you "open the terminals yourself" instead of asking a person to.',
694
704
  'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
695
705
  'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. All of this works only under the ThinkPool account bridge; a standalone room sees just its own terminals.',
696
706
  'SUBAGENT POLICY: in this room, delegated work goes through the Ensemble. When you fan out research, parallel sub-tasks, reviews, or any substantive multi-step delegated work, open spawn_terminal lanes — they are visible in the room, the people can watch and steer them, and every other lane can peer at their work. Do NOT reach for the built-in Task/Agent subagents for that work: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble. The only exception is a trivial, seconds-scale read-only lookup where a room lane would be pure overhead.',
@@ -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
@@ -240,12 +240,21 @@ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {
240
240
  // this stays a PURE function (no Date.now inside; the clock is injected, like
241
241
  // spawnCount was before).
242
242
  export const SPAWN = {
243
- maxHop: 1, // human turn = hop 0; a spawned/injected lane = hop ≥ 1, CANNOT spawn onward
244
- perWindowCap: 4, // spawn_terminal calls allowed per rolling window (burst breaker)
243
+ maxHop: 2, // human turn = hop 0; its spawned lane (hop 1, a "conductor") MAY spawn
244
+ // workers (hop 2); hop ≥ 2 can never spawn onward. Depth-2 cascades,
245
+ // fork-bomb still impossible by construction — every level also passes
246
+ // machineMax + the plan ceiling + the burst window + the kill-switch.
247
+ // Was 1 until 2026-07-10 (docs/specs/2026-07-10-cascade-spawn-depth.md):
248
+ // conductors dispatched by a person's lane could not dispatch at all.
249
+ perWindowCap: 6, // spawn_terminal calls allowed per rolling window (burst breaker);
250
+ // 6 matches plusMaxSpawned so one wave can open a full crew (was 4)
245
251
  windowMs: 10 * 60 * 1000, // sliding window over which perWindowCap applies (10 min)
246
252
  freeMaxSpawned: 3, // Free plan dispatch ceiling (concurrent spawned lanes)
247
253
  plusMaxSpawned: 6, // Plus plan dispatch ceiling
248
- machineMax: 10, // hard cap on TOTAL live lanes (the laptop/runaway backstop)
254
+ machineMax: 12, // hard cap on TOTAL live lanes (the laptop/runaway backstop). 10 → 12
255
+ // 2026-07-10: finished-but-open lanes were starving slots. NOT higher:
256
+ // at ~10 live lanes CPU contention corrupted Lighthouse runs that day —
257
+ // this cap protects the host and measurement quality, not just runaway.
249
258
  }
250
259
 
251
260
  // Plan → dispatch ceiling. Anything not 'plus' is treated as Free (the safe default).
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.182",
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.