thinkpool-pair 0.7.350 → 0.7.352

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
@@ -2259,7 +2259,7 @@ function worktreeSnapshot(cwd) {
2259
2259
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2260
2260
  // persist to the host file; tool calls round-trip through the perm card; the
2261
2261
  // rolling log replays to joiners and survives bridge restarts (session-store).
2262
- function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
2262
+ function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids }) {
2263
2263
  if (sessions.has(id)) return
2264
2264
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2265
2265
  mode = structuredModeForSlice(runtime, { mode, sliceType, flowRole })
@@ -2302,7 +2302,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2302
2302
  const restoredRawLog = Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : []
2303
2303
  const restoredLog = boundStructuredLog(restoredRawLog)
2304
2304
  const restoredOffset = restoredRawLog.length - restoredLog.length
2305
- const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2305
+ const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), receivedTurnCids: new Set(Array.isArray(receivedTurnCids) ? receivedTurnCids.filter(Boolean).slice(-1000) : []), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2306
2306
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2307
2307
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
2308
2308
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -2592,7 +2592,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2592
2592
  // restart. Without this, sessionData omitted it → on restart the resumed session
2593
2593
  // re-launched on the host default (Opus) regardless of the last switch, and the
2594
2594
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2595
- 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, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, 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 })
2595
+ 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, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, 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, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
2596
2596
  const persist = () => saveSession(room, id, sessionData())
2597
2597
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2598
2598
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -4481,6 +4481,30 @@ channel
4481
4481
  // and close it. Re-check identity after the await in case the lane was closed.
4482
4482
  await waitForAbortBarrier(s)
4483
4483
  if (sessions.get(payload.term) !== s || !s.session) return
4484
+ // Mobile can suspend after showing its optimistic row but before this frame
4485
+ // is consumed. The browser therefore retries by cid until this explicit,
4486
+ // bridge-authored receipt arrives. Collapse every retry before dispatch:
4487
+ // in-memory receipts cover concurrent/live duplicates and the persisted log
4488
+ // covers an ack lost across a bridge reconnect/restart.
4489
+ const turnCid = payload.cid && String(payload.cid)
4490
+ if (turnCid) {
4491
+ if (!s.receivedTurnCids) s.receivedTurnCids = new Set()
4492
+ const alreadyReceived = s.receivedTurnCids.has(turnCid)
4493
+ || s.log?.some((event) => event?.cid === turnCid && (event.kind === 'you' || event.kind === 'control'))
4494
+ if (!alreadyReceived) {
4495
+ s.receivedTurnCids.add(turnCid)
4496
+ if (s.receivedTurnCids.size > 1000) {
4497
+ const oldest = s.receivedTurnCids.values().next().value
4498
+ s.receivedTurnCids.delete(oldest)
4499
+ }
4500
+ // Persist the idempotency key before acknowledging it. If the bridge
4501
+ // restarts after this receipt but before the browser observes the ack,
4502
+ // the browser's retry is still collapsed instead of running twice.
4503
+ s.flush?.()
4504
+ }
4505
+ bcast('code-turn-ack', { term: payload.term, cid: turnCid, duplicate: alreadyReceived })
4506
+ if (alreadyReceived) return
4507
+ }
4484
4508
  markActivity()
4485
4509
  // B2/C: a real human turn is hop 0 with fresh cross-terminal budgets. This is
4486
4510
  // the loop-breaker reset point — an injected (cross-posted) turn does NOT come
@@ -4910,7 +4934,7 @@ channel
4910
4934
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
4911
4935
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
4912
4936
  // lanes keep their worktree cwd + the ability to mark done.
4913
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
4937
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
4914
4938
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
4915
4939
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
4916
4940
  // (mid-turn needs auto-resume; flow needs its lane live).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.350",
3
+ "version": "0.7.352",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
package/terminal-name.mjs CHANGED
@@ -221,6 +221,10 @@ export function cleanTerminalName(value) {
221
221
  if (!name || GENERIC.test(name) || nativeClaudeProviderAccessFailed(name)) return null
222
222
  if (/^(?:error|failed|sorry|unable|i (?:cannot|can't)|rate limit|request failed)\b/i.test(name)) return null
223
223
  if (name.length > 48) name = name.slice(0, 48).replace(/\s+\S*$/, '').trim()
224
+ const words = name.split(/\s+/).filter(Boolean)
225
+ const compactCjk = words.length === 1
226
+ && /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(name)
227
+ if ((words.length < 2 && !compactCjk) || words.length > 4) return null
224
228
  return name || null
225
229
  }
226
230
 
@@ -264,7 +268,7 @@ const taskTitle = (value) => {
264
268
  if (!body) return null
265
269
  const grammar = analyzeGrammar(body)
266
270
  if (grammar.negativePreference) {
267
- const subject = contentWords(negativePreferenceSubject(grammar)).slice(0, 4)
271
+ const subject = contentWords(negativePreferenceSubject(grammar)).slice(0, 3)
268
272
  return subject.length ? cleanTerminalName(['Avoid', ...subject.map(titleWord)].join(' ')) : null
269
273
  }
270
274
  const informationRequest = grammar.informationRequest
@@ -282,11 +286,11 @@ const taskTitle = (value) => {
282
286
  if (!verb && /\bopen(?:s|ed|ing)?\b/i.test(body)) verb = 'Open'
283
287
 
284
288
  if (/\bautomatic(?:ally)?\b[\s\S]{0,24}\bnam(?:e|ing)\b/i.test(signalText) && /\bterminals?\b/i.test(signalText)) {
285
- if (/\bcontext\b[\s\S]{0,24}\broom\b|\broom\b[\s\S]{0,24}\bcontext\b/i.test(signalText)) return 'Auto-Name Terminals From Room Context'
289
+ if (/\bcontext\b[\s\S]{0,24}\broom\b|\broom\b[\s\S]{0,24}\bcontext\b/i.test(signalText)) return 'Auto-Name Terminals From Context'
286
290
  verb = 'Auto-Name'
287
291
  }
288
- if (verb === 'Snap' && /\bterminal row\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Terminal Row Into View'
289
- if (verb === 'Snap' && /\bclicked terminal\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Clicked Terminal Into View'
292
+ if (verb === 'Snap' && /\bterminal row\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Terminal Row View'
293
+ if (verb === 'Snap' && /\bclicked terminal\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Clicked Terminal View'
290
294
 
291
295
  let domain = []
292
296
  let objectText = body
@@ -305,7 +309,9 @@ const taskTitle = (value) => {
305
309
  const available = object.findIndex((word) => /^available$/i.test(word))
306
310
  if (available > 0) object = [object[available], ...object.slice(0, available), ...object.slice(available + 1)]
307
311
  }
308
- const picked = [...domain, ...object].filter((word, index, all) => all.findIndex((other) => other.toLowerCase() === word.toLowerCase()) === index).slice(0, 4)
312
+ const picked = [...domain, ...object]
313
+ .filter((word, index, all) => all.findIndex((other) => other.toLowerCase() === word.toLowerCase()) === index)
314
+ .slice(0, verb ? 3 : 4)
309
315
  if (!picked.length) return null
310
316
  return cleanTerminalName([verb, ...picked.map(titleWord)].filter(Boolean).join(' '))
311
317
  }