thinkpool-pair 0.7.204 → 0.7.205

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
@@ -1759,7 +1759,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1759
1759
  // restart. Without this, sessionData omitted it → on restart the resumed session
1760
1760
  // re-launched on the host default (Opus) regardless of the last switch, and the
1761
1761
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1762
- const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null })
1762
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
1763
1763
  const persist = () => saveSession(room, id, sessionData())
1764
1764
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1765
1765
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -2098,7 +2098,8 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2098
2098
  // Context-carry (2026-07-08): a non-resumable re-open (provider switch / non-resumable
2099
2099
  // restart) hands the FRESH session a plain-text recap of the visible log as its first
2100
2100
  // input, so the agent picks up the work instead of starting blank mid-conversation. Held
2101
- // here (transient — NOT in sessionData, a re-restart re-derives it from the log) and fired
2101
+ // here and persist it until consumed, so a restart between /compact and the next
2102
+ // human turn cannot strand a fresh Codex thread without its recap. It is fired
2102
2103
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2103
2104
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2104
2105
  entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
@@ -2286,6 +2287,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2286
2287
  if (evt.kind === 'system' && entry.pendingRecap) {
2287
2288
  const recap = entry.pendingRecap
2288
2289
  entry.pendingRecap = null
2290
+ entry.flush?.()
2289
2291
  try { entry.session?.sendTurn(recap) } catch { /* not live — stays idle, user can continue manually */ }
2290
2292
  process.stderr.write(`\n ◆ carried ${recap.length}c context recap into fresh session (${id.slice(0, 8)}) after provider/restore switch.\n`)
2291
2293
  }
@@ -2879,11 +2881,11 @@ channel
2879
2881
  pushLog(s, evt)
2880
2882
  bcast('code-event', { term: payload.term, evt })
2881
2883
  }
2882
- // Session controls (/model, /clear) are NOT agent turns — they change the
2884
+ // Session controls (/model, /clear, /usage, and Codex /compact) are NOT agent turns — they change the
2883
2885
  // shared session config. Emitting a `you` line for them strands the reader's
2884
2886
  // inferred "working…" state (no turn → no result to clear it; the old bug).
2885
- // Instead announce a ◆ control line both readers see. /compact + any other
2886
- // slash still flow through as a real, self-terminating turn.
2887
+ // Instead announce a ◆ control line both readers see. Claude /compact + any
2888
+ // other slash still flow through as a real, self-terminating turn.
2887
2889
  const ctlLine = (ctlText) => {
2888
2890
  const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid }
2889
2891
  pushLog(s, evt)
@@ -2936,7 +2938,33 @@ channel
2936
2938
  // No persisted ctl line — the live indicator + the CompactionCard are the record.
2937
2939
  if (/^\/compact\b/.test(text)) {
2938
2940
  if (s.runtime === 'codex') {
2939
- ctlLine('Codex manages context compaction automatically')
2941
+ if (s.session.turnActive) {
2942
+ ctlLine('finish or stop the current Codex turn before compacting context')
2943
+ return
2944
+ }
2945
+ const recap = buildRecapFromLog(s.log, RECAP_CAP, { reason: 'compact' })
2946
+ if (!recap) {
2947
+ ctlLine('nothing to compact — context unchanged')
2948
+ return
2949
+ }
2950
+ if (s.session.clearContext?.() === false) {
2951
+ ctlLine('Codex context compaction unavailable right now')
2952
+ return
2953
+ }
2954
+ s.pendingRecap = recap
2955
+ const ce = { kind: 'compaction', trigger: 'manual', preTokens: s.lastUsage?.ctx?.used || null, by: payload.by }
2956
+ pushLog(s, ce)
2957
+ bcast('code-event', { term: payload.term, evt: ce })
2958
+ // The new native thread has only the bounded recap queued for the next
2959
+ // human turn. Reset the visible meter to that conservative text estimate.
2960
+ if (s.lastUsage?.ctx?.max) {
2961
+ const used = Math.ceil(recap.length / 4)
2962
+ const max = s.lastUsage.ctx.max
2963
+ const usage = { kind: 'usage', ctx: { ...s.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
2964
+ s.lastUsage = usage
2965
+ bcast('code-event', { term: payload.term, evt: usage })
2966
+ }
2967
+ s.flush?.()
2940
2968
  return
2941
2969
  }
2942
2970
  s.compacting = true
@@ -2956,7 +2984,7 @@ channel
2956
2984
  // it degrades to "plan meters unavailable" rather than printing an inferred reset.
2957
2985
  if (/^\/usage\s*$/.test(text)) {
2958
2986
  ctlLine(usageReportLine(s.model, s.log))
2959
- planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
2987
+ if (s.runtime !== 'codex') planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
2960
2988
  return
2961
2989
  }
2962
2990
  // Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
@@ -2964,7 +2992,7 @@ channel
2964
2992
  // — a separate recap turn would race + double-fire) and clear the flag so the init handler
2965
2993
  // no-ops. The visible `you` echo is unchanged; the recap is context for the agent only.
2966
2994
  let sendText = text
2967
- if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null }
2995
+ if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null; s.flush?.() }
2968
2996
  s.session.sendTurn(sendText)
2969
2997
  echoYou()
2970
2998
  // Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
@@ -3193,7 +3221,7 @@ channel
3193
3221
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3194
3222
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3195
3223
  // lanes keep their worktree cwd + the ability to mark done.
3196
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage,
3224
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: rec.carryRecap,
3197
3225
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3198
3226
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3199
3227
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -42,7 +42,7 @@ export class CodexEventMapper {
42
42
  /**
43
43
  * @param {{onEvent:(evt)=>void, model?:string|null, sessionId?:string|null}} opts
44
44
  */
45
- constructor({ onEvent, model = null, sessionId = null, fsImpl = fs }) {
45
+ constructor({ onEvent, model = null, sessionId = null, fsImpl = fs, contextWindowForModel = () => null }) {
46
46
  this._onEvent = onEvent
47
47
  this._model = model || null
48
48
  this._sessionId = sessionId || null
@@ -50,6 +50,7 @@ export class CodexEventMapper {
50
50
  this._fileBefore = new Map() // item.id → [{ path, kind, content }]
51
51
  this._fs = fsImpl
52
52
  this._systemSent = false
53
+ this._contextWindowForModel = contextWindowForModel
53
54
  }
54
55
 
55
56
  /** Emit a room-event; never let a consumer throw into the stream. */
@@ -143,7 +144,15 @@ export class CodexEventMapper {
143
144
  const u = ev.usage || {}
144
145
  // Map Codex usage → Claude-shape usage (what event-id.mjs folds into cost).
145
146
  // reasoning_output_tokens are billed as output → fold in (conservative).
146
- const out = (u.output_tokens || 0) + (u.reasoning_output_tokens || 0)
147
+ const out = (Number(u.output_tokens) || 0) + (Number(u.reasoning_output_tokens) || 0)
148
+ const totalInput = Number(u.input_tokens) || 0
149
+ const cachedInput = Math.min(totalInput, Number(u.cached_input_tokens) || 0)
150
+ let max = null
151
+ try { max = Number(this._contextWindowForModel(this._model)) } catch { /* unknown catalog → omit meter */ }
152
+ if (Number.isFinite(max) && max > 0) {
153
+ const used = totalInput + out
154
+ this._emit({ kind: 'usage', ctx: { used, max, pct: Math.min(100, Math.max(0, Math.round((used / max) * 100))), over: used > max, model: this._model } })
155
+ }
147
156
  this._emit({
148
157
  kind: 'result',
149
158
  subtype: 'success',
@@ -151,8 +160,11 @@ export class CodexEventMapper {
151
160
  model: this._model,
152
161
  costUsd: null,
153
162
  usage: {
154
- input_tokens: u.input_tokens || 0,
155
- cache_read_input_tokens: u.cached_input_tokens || 0,
163
+ // Claude-shape fields are mutually exclusive buckets. Codex reports
164
+ // cached_input_tokens as a subset of input_tokens, so subtract it here;
165
+ // usageReportLine adds the buckets and must not double-count cache hits.
166
+ input_tokens: totalInput - cachedInput,
167
+ cache_read_input_tokens: cachedInput,
156
168
  cache_creation_input_tokens: 0,
157
169
  output_tokens: out,
158
170
  },
package/codex-session.mjs CHANGED
@@ -65,6 +65,19 @@ export function readCodexModels({ home = process.env.CODEX_HOME || path.join(os.
65
65
  } catch { return [] }
66
66
  }
67
67
 
68
+ // The CLI's current model catalog is the authority for the runtime's usable model
69
+ // window. Keep this separate from readCodexModels so the public announce shape stays
70
+ // stable while the driver can emit a truthful context meter for every Codex model.
71
+ export function readCodexContextWindow(model, { home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
72
+ if (!model) return null
73
+ try {
74
+ const data = JSON.parse(fsImpl.readFileSync(path.join(home, 'models_cache.json'), 'utf8'))
75
+ const hit = (Array.isArray(data?.models) ? data.models : []).find((m) => m?.slug === model)
76
+ const max = Number(hit?.context_window)
77
+ return Number.isFinite(max) && max > 0 ? Math.floor(max) : null
78
+ } catch { return null }
79
+ }
80
+
68
81
  export function normalizeCodexSandbox(value) {
69
82
  return SAFE_SANDBOXES.has(value) ? value : DEFAULT_SANDBOX
70
83
  }
@@ -150,7 +163,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
150
163
  let chain = Promise.resolve()
151
164
  const queue = []
152
165
 
153
- const mapper = new CodexEventMapper({ onEvent, model: model || null })
166
+ const mapper = new CodexEventMapper({
167
+ onEvent,
168
+ model: model || null,
169
+ contextWindowForModel: (id) => readCodexContextWindow(id),
170
+ })
154
171
 
155
172
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
156
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.204",
3
+ "version": "0.7.205",
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/recap.mjs CHANGED
@@ -42,6 +42,10 @@ const FRAMING = {
42
42
  header: '[CONTEXT CARRY — this lane kept talking while your machine was asleep]',
43
43
  body: "Your bridge was offline. The lane stayed open and the people in this room kept planning here with Pool — a chat-only assistant with no file access and no tools. Everything below already happened and they have all seen it. NOTHING WAS RUN ON YOUR BEHALF: no commands were executed, no files were changed, and no plan below has been carried out. Do NOT re-greet and do NOT re-answer what is already answered. Read the recap, then wait for the people to tell you what to do with it — the plan below is a plan, not an instruction you have already been given.",
44
44
  },
45
+ compact: {
46
+ header: '[CONTEXT COMPACTED — continue this Codex lane from its bounded recap]',
47
+ body: "The person intentionally compacted this lane. Your prior native thread was closed and the visible conversation below is its bounded continuation context. Do NOT re-greet, do NOT re-answer completed work, and do NOT repeat tool calls that already succeeded. Continue from the person's next message using this recap as memory.",
48
+ },
45
49
  }
46
50
 
47
51
  /* Context-carry recap for a lane whose SDK memory CAN'T be resumed — a cross-provider