thinkpool-pair 0.7.128 → 0.7.130

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
@@ -83,7 +83,7 @@ const flowRedispatch = new Map()
83
83
  const flowBudgets = new Map()
84
84
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision } from './cross-terminal.mjs'
85
85
  import { turnInFlight } from './update-gate.mjs'
86
- import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
86
+ import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, loadSummary, saveSummary } from './session-store.mjs'
87
87
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
88
88
  import { makeThrottledTrack } from './presence.mjs'
89
89
 
@@ -1222,12 +1222,16 @@ function restoredTurnOpen(log) {
1222
1222
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1223
1223
  // persist to the host file; tool calls round-trip through the perm card; the
1224
1224
  // rolling log replays to joiners and survives bridge restarts (session-store).
1225
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots }) {
1225
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt }) {
1226
1226
  if (sessions.has(id)) return
1227
1227
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1228
1228
  // session store so the Ensemble flag survives a bridge restart (else a respin
1229
1229
  // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
1230
- const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, model: model || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null }
1230
+ const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, model: model || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
1231
+ // Stable creation order — persisted so a bridge restart restores tabs in the SAME
1232
+ // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
1233
+ // first transcript event ts, so even the first post-fix restart is ordered right.
1234
+ openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now() }
1231
1235
  // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
1232
1236
  // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
1233
1237
  // an old seq, which is what keeps the between-turns restart dup-free (see
@@ -1340,7 +1344,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1340
1344
  // subagent-block; without cwd it runs outside its worktree; without rolePrompt it loses its
1341
1345
  // Flow role. blockSubagents / onLaneDone / onReviewVerdict / onSubmitPlan all derive from
1342
1346
  // these, so restoring them restores the whole behavior.
1343
- const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [] })
1347
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null })
1344
1348
  const persist = () => saveSession(room, id, sessionData())
1345
1349
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1346
1350
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -2213,6 +2217,13 @@ channel
2213
2217
  // re-open within a page load).
2214
2218
  .on('broadcast', { event: 'code-summarize' }, async ({ payload }) => {
2215
2219
  const by = payload?.by || null
2220
+ // Peek — any device opening the panel fetches the cached recap (cross-device +
2221
+ // cross-restart persistence) WITHOUT spending a model call. No cache → silent.
2222
+ if (payload?.peek) {
2223
+ const cached = loadSummary(room)
2224
+ if (cached && cached.text) bcast('code-summary', { status: 'done', ...cached })
2225
+ return
2226
+ }
2216
2227
  bcast('code-summary', { status: 'generating', by })
2217
2228
  try {
2218
2229
  // Pull assistant/user text out of a transcript-event array, keep the tail.
@@ -2253,18 +2264,23 @@ channel
2253
2264
  ? `\n\nThere are also ${closedIds.length} CLOSED terminal(s). Recent excerpts (do NOT name them individually):\n${(closedDigests.join('\n---\n') || '(no retained detail)').slice(0, 1400)}`
2254
2265
  : ''
2255
2266
 
2256
- const prompt = `Write a concise recap of this multi-terminal coding session for someone glancing at it. Rules:
2257
- - NAME each LIVE terminal and say whether it is working or done, and briefly what it is doing/did.
2258
- - Do NOT name the CLOSED terminals individually — fold them into ONE aggregate clause ("plus N closed lanes that handled …").
2259
- - 2 to 4 sentences total, plain prose, no markdown, no headers, no bullet lists.
2267
+ const prompt = `Summarize this multi-terminal coding session as a STRUCTURED LIST. Output ONLY these lines, nothing else:
2268
+ - ONE line per LIVE terminal, EXACTLY: <terminal name> | <working or done> | <one clause, max 12 words, no trailing period>
2269
+ - If there are CLOSED terminals, ONE final line, EXACTLY: CLOSED | <count> | <one aggregate clause about what they handled — do NOT name them>
2270
+ Use the pipe "|" as the separator. No headers, no markdown, no bullets, no extra prose. Example:
2271
+ Bridge Whip | working | SDK 0.7.128, detection gates live
2272
+ Reddit | done | GTM strategy mapped
2273
+ CLOSED | 52 | flow-trigger churn and @pool integration
2260
2274
 
2261
2275
  ${liveBlock}${closedBlock}
2262
2276
 
2263
- Recap:`
2277
+ Output:`
2264
2278
 
2265
2279
  const res = await oneShotSummary({ prompt, env: env || process.env, cwd: cwd || process.cwd() })
2266
2280
  if (!res || !res.text) { bcast('code-summary', { status: 'error', error: 'Summary model unavailable on this provider.', by }); return }
2267
- bcast('code-summary', { status: 'done', text: res.text, model: res.model, generatedAt: Date.now(), by })
2281
+ const summary = { text: res.text, model: res.model, generatedAt: Date.now() }
2282
+ try { saveSummary(room, summary) } catch { /* cache is best-effort */ }
2283
+ bcast('code-summary', { status: 'done', ...summary, by })
2268
2284
  process.stderr.write(`\n ◆ session summary generated (${res.model}) for ${by || 'someone'}.\n`)
2269
2285
  } catch (e) {
2270
2286
  bcast('code-summary', { status: 'error', error: String(e?.message || e).slice(0, 140), by })
@@ -2290,11 +2306,16 @@ Recap:`
2290
2306
  // Closed sessions are unlinked by deleteSession, so a file existing on disk ==
2291
2307
  // a session that was live at shutdown (never an explicitly closed one).
2292
2308
  const all = loadAll(room)
2309
+ // Restore in stable creation order (openedAt) so the tab strip comes back in the
2310
+ // SAME order across a bridge restart — NOT readdir/filesystem order, which reshuffled
2311
+ // the tabs (Max, 2026-07-02). Legacy recs fall back to their first event ts / savedAt.
2312
+ const okey = (r) => r.openedAt || (Array.isArray(r.log) ? (r.log.find((e) => e?.ts)?.ts || 0) : 0) || r.savedAt || 0
2313
+ all.sort((a, b) => okey(a) - okey(b))
2293
2314
  if (all.length) for (const rec of all) {
2294
2315
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
2295
2316
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
2296
2317
  // lanes keep their worktree cwd + the ability to mark done.
2297
- openStructured({ id: rec.id, 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, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots })
2318
+ openStructured({ id: rec.id, 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, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt })
2298
2319
  const re = sessions.get(rec.id)
2299
2320
  if (re && rec.flowReviewTarget) re.flowReviewTarget = rec.flowReviewTarget
2300
2321
  // S4 — a resumed re-dispatch lane keeps its surviving revert target across a bridge restart.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.128",
3
+ "version": "0.7.130",
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/session-store.mjs CHANGED
@@ -191,3 +191,14 @@ export function loadNames(room) {
191
191
  export function saveNames(room, names) {
192
192
  try { ensureDir(room); fs.writeFileSync(namesFile(room), JSON.stringify(names || {})) } catch { /* noop */ }
193
193
  }
194
+
195
+ // Session recap cache (2026-07-02, Session Info panel). The last on-demand summary,
196
+ // stored as a dotfile (NOT *.json → never in loadAll) so it survives a bridge restart
197
+ // and any device that opens the panel can fetch it (persists across devices). One per room.
198
+ const summaryFile = (room) => path.join(dir(room), '.summary')
199
+ export function loadSummary(room) {
200
+ try { return JSON.parse(fs.readFileSync(summaryFile(room), 'utf8')) || null } catch { return null }
201
+ }
202
+ export function saveSummary(room, summary) {
203
+ try { ensureDir(room); fs.writeFileSync(summaryFile(room), JSON.stringify(summary || null)) } catch { /* noop */ }
204
+ }