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 +32 -15
- package/claude-session.mjs +25 -6
- package/cross-terminal.mjs +12 -3
- package/event-id.mjs +15 -4
- package/package.json +1 -1
- package/turn-stall.mjs +19 -1
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
|
|
1904
|
-
// TP_SPAWN_OFF kill-switch). A
|
|
1905
|
-
//
|
|
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
|
|
1951
|
-
//
|
|
1952
|
-
|
|
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
|
|
2283
|
-
//
|
|
2284
|
-
//
|
|
2285
|
-
//
|
|
2286
|
-
//
|
|
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
|
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).
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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/cross-terminal.mjs
CHANGED
|
@@ -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:
|
|
244
|
-
|
|
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:
|
|
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
|
|
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.
|