thinkpool-pair 0.7.174 → 0.7.176

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/account.mjs CHANGED
@@ -594,6 +594,18 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
594
594
  provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
595
595
  })
596
596
 
597
+ // ENABLE presence delivery + the self-echo's ground truth. supabase-js only asks
598
+ // the server for presence state when a presence callback is bound at join time —
599
+ // with no listener, presenceState() stays {} FOREVER even while our own track()
600
+ // lands fine. That blind spot made the 0.7.174 self-echo watchdog false-positive
601
+ // respawn the supervisor every ~2.5 min ("the bridge disconnects and reconnects
602
+ // randomly", 2026-07-09 — nine respawns in an hour, track=ok on every one).
603
+ // Binding sync does two jobs: turns presence delivery ON (the echo becomes real)
604
+ // and stamps presenceSyncSeenAt — the watchdog's fail-safe (no proven delivery →
605
+ // no escalation, ever). Proof: /tmp echo-proof (no listener → {}, listener → key).
606
+ let presenceSyncSeenAt = 0
607
+ acct.on('presence', { event: 'sync' }, () => { presenceSyncSeenAt = Date.now() })
608
+
597
609
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
598
610
  // (network blip, or a token swap mid-flight) rejoins the channel and must
599
611
  // re-announce, or the dashboard would read "no bridge" until the next restart.
@@ -1020,6 +1032,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1020
1032
  const presenceWatch = setInterval(() => {
1021
1033
  if (stopping) return
1022
1034
  const now = Date.now()
1035
+ // FAIL-SAFE: escalation requires PROVEN presence delivery on this channel (≥1
1036
+ // sync ever received). An empty presenceState() without it means WE can't see —
1037
+ // not that the track failed; respawning on that is the 0.7.174 false-positive
1038
+ // loop. A real zombie (the bug this watchdog exists for) still escalates:
1039
+ // delivery was proven earlier, then our key vanished from the server's state.
1040
+ if (!presenceSyncSeenAt) { selfEchoMissingSince = 0; return }
1023
1041
  let present = false
1024
1042
  try { present = !!acct.presenceState?.()[machine] } catch { present = false }
1025
1043
  if (present) { selfEchoMissingSince = 0; presenceRecycledForMissing = 0; return }
package/event-id.mjs CHANGED
@@ -249,67 +249,13 @@ export function usageReportLine (sessionModel, logEvents) {
249
249
  return `usage · ${turns} · ${perStr}${total}${cost} · tokens + cost only (no plan/rate-limit meters)`
250
250
  }
251
251
 
252
- // Default hard cap for the context-carry recap (chars of transcript body, framing extra).
253
- export const RECAP_CAP = 20000
254
-
255
- // Context-carry recap for a lane whose SDK memory CAN'T be resumed (a cross-provider
256
- // switch, or a bridge-restart restore where canResume() is false). Such a lane re-opens
257
- // under the SAME id with the visible transcript replayed but a FRESH, blank backend — so
258
- // the agent starts amnesiac mid-conversation (the 2026-07-08 lane 109d22e1 "this is the
259
- // first message in my lane" incident). Raw SDK resume can't cross a provider (signed
260
- // thinking blocks + provider-specific tool_use records get rejected by a foreign backend),
261
- // so we carry the conversation at the TEXT layer instead: synthesize a plain-text recap
262
- // from the saved log — the people's turns (`you`) + the assistant's own text replies
263
- // (`assistant` text blocks; thinking + tool_use are dropped, they don't survive a backend
264
- // hop and are noise) — newest-weighted under a hard char cap, framed so the agent RESUMES
265
- // rather than re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
266
- //
267
- // capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
268
- // bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
269
- export function buildRecapFromLog(log, cap = RECAP_CAP) {
270
- if (!Array.isArray(log) || !log.length) return ''
271
- const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
272
- if (!budget) return ''
273
- // Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
274
- // `assistant` = this agent's own earlier reply (its text blocks only).
275
- const turns = []
276
- for (const e of log) {
277
- if (!e || typeof e !== 'object') continue
278
- if (e.kind === 'you') {
279
- const t = String(e.text || '').trim()
280
- if (t) turns.push({ who: 'PERSON', text: t })
281
- } else if (e.kind === 'assistant') {
282
- const t = (Array.isArray(e.blocks) ? e.blocks : [])
283
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
284
- .map((b) => b.text.trim())
285
- .join('\n')
286
- if (t) turns.push({ who: 'YOU', text: t })
287
- }
288
- }
289
- if (!turns.length) return ''
290
- // Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
291
- // then restore chronological order. A single newest turn bigger than the whole budget is
292
- // still kept (truncated) so the LIVE task is never dropped to the cap.
293
- const kept = []
294
- let used = 0
295
- let omitted = false
296
- for (let i = turns.length - 1; i >= 0; i--) {
297
- let line = `${turns[i].who}: ${turns[i].text}`
298
- if (used + line.length > budget) {
299
- if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
300
- line = line.slice(0, budget) // newest turn alone overflows → clip, still include
301
- }
302
- kept.push(line)
303
- used += line.length
304
- }
305
- kept.reverse()
306
- const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
307
- return [
308
- '[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
309
- "Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up.",
310
- '',
311
- '--- conversation so far (oldest → newest) ---',
312
- body,
313
- '--- end recap ---',
314
- ].join('\n')
315
- }
252
+ /* The context-carry recap moved to ./recap.mjs (ADR-0010 / S4, 2026-07-09).
253
+ *
254
+ * Why: the WEB CLIENT needs buildRecapFromLog to carry a lane's bridgeless
255
+ * conversation across a bridge wake, and this module imports `node:crypto` —
256
+ * unresolvable in a browser bundle. The pure half lives in an import-free file
257
+ * both sides can load; these re-exports keep every existing bridge-side import
258
+ * (`from './event-id.mjs'`) working untouched, including bridge/recap.test.mjs.
259
+ *
260
+ * recap.mjs must stay in bridge/package.json `files` — event-id.mjs imports it. */
261
+ export { RECAP_CAP, buildRecapFromLog } from './recap.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.174",
3
+ "version": "0.7.176",
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": {
@@ -16,6 +16,7 @@
16
16
  "turn-stall.mjs",
17
17
  "update-gate.mjs",
18
18
  "event-id.mjs",
19
+ "recap.mjs",
19
20
  "transcript-sanitize.mjs",
20
21
  "session-store.mjs",
21
22
  "cross-terminal.mjs",
package/recap.mjs ADDED
@@ -0,0 +1,112 @@
1
+ /* recap.mjs — the text-layer context carry. PURE. NO IMPORTS. NO NODE BUILTINS.
2
+ *
3
+ * Extracted from event-id.mjs on 2026-07-09 (ADR-0010 / slice S4) for one reason:
4
+ * the WEB CLIENT needs this function now, and event-id.mjs imports `node:crypto`
5
+ * (for stampEvent's randomUUID default), which Vite cannot resolve for a browser
6
+ * bundle. Rather than write a second windower — the spec's §b instruction is
7
+ * literally "Do not write a second windower" — the pure half moves to its own
8
+ * file that both sides import.
9
+ *
10
+ * bridge/event-id.mjs re-exports these (every existing import keeps working)
11
+ * src/pages/code/poolTurn.js imports this file directly
12
+ *
13
+ * ⚠ KEEP THIS FILE FREE OF IMPORTS. The moment it imports `node:*`, the web build
14
+ * breaks. There is no lint rule for this; there is only this comment.
15
+ *
16
+ * ⚠ TARBALL: this file is imported by event-id.mjs, so it MUST appear in
17
+ * bridge/package.json `files`. A publish that omits it boots a bridge that
18
+ * crashes on `import './recap.mjs'`. (feedback_npm_tarball_completeness.)
19
+ */
20
+
21
+ // Default hard cap for the context-carry recap (chars of transcript body, framing extra).
22
+ export const RECAP_CAP = 20000
23
+
24
+ /* Why a lane is being handed a recap instead of a resumed session. The framing text
25
+ differs because the LIE each one must avoid differs:
26
+
27
+ 'switch' — a model/provider switch ended the SDK session. The agent's own memory is
28
+ gone but nothing else happened; the transcript above is entirely its own work.
29
+
30
+ 'wake' — ADR-0010 §d. The bridge was ASLEEP. The lane stayed alive and the people
31
+ kept talking to Pool (client-side BYOK, chat-only, no file access). So the recap
32
+ contains turns the agent DID NOT produce and work it DID NOT do. The framing has to
33
+ say that, and has to say that nothing ran on its behalf — otherwise the agent
34
+ assumes the plan below was already executed. That assumption is how "nothing
35
+ autofires" becomes a lie the agent tells itself. */
36
+ const FRAMING = {
37
+ switch: {
38
+ header: '[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
39
+ body: "Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up.",
40
+ },
41
+ wake: {
42
+ header: '[CONTEXT CARRY — this lane kept talking while your machine was asleep]',
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
+ },
45
+ }
46
+
47
+ /* Context-carry recap for a lane whose SDK memory CAN'T be resumed — a cross-provider
48
+ switch, a bridge-restart restore where canResume() is false, or (ADR-0010) a bridge
49
+ WAKE under a lane that spent the outage in Pool posture. Such a lane re-opens under the
50
+ SAME id with the visible transcript replayed but a FRESH, blank backend — so the agent
51
+ starts amnesiac mid-conversation (the 2026-07-08 lane 109d22e1 "this is the first
52
+ message in my lane" incident). Raw SDK resume can't cross a provider (signed thinking
53
+ blocks + provider-specific tool_use records get rejected by a foreign backend), so we
54
+ carry the conversation at the TEXT layer instead: synthesize a plain-text recap from the
55
+ saved log — the people's turns (`you`) + the assistant's own text replies (`assistant`
56
+ text blocks; thinking + tool_use are dropped, they don't survive a backend hop and are
57
+ noise) — newest-weighted under a hard char cap, framed so the agent RESUMES rather than
58
+ re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
59
+
60
+ capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
61
+ bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
62
+
63
+ `reason` selects the framing only; the windowing is identical and the default
64
+ ('switch') is byte-for-byte what this function returned before the S4 extraction. */
65
+ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } = {}) {
66
+ if (!Array.isArray(log) || !log.length) return ''
67
+ const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
68
+ if (!budget) return ''
69
+ // Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
70
+ // `assistant` = this agent's own earlier reply (its text blocks only).
71
+ const turns = []
72
+ for (const e of log) {
73
+ if (!e || typeof e !== 'object') continue
74
+ if (e.kind === 'you') {
75
+ const t = String(e.text || '').trim()
76
+ if (t) turns.push({ who: 'PERSON', text: t })
77
+ } else if (e.kind === 'assistant') {
78
+ const t = (Array.isArray(e.blocks) ? e.blocks : [])
79
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
80
+ .map((b) => b.text.trim())
81
+ .join('\n')
82
+ if (t) turns.push({ who: 'YOU', text: t })
83
+ }
84
+ }
85
+ if (!turns.length) return ''
86
+ // Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
87
+ // then restore chronological order. A single newest turn bigger than the whole budget is
88
+ // still kept (truncated) so the LIVE task is never dropped to the cap.
89
+ const kept = []
90
+ let used = 0
91
+ let omitted = false
92
+ for (let i = turns.length - 1; i >= 0; i--) {
93
+ let line = `${turns[i].who}: ${turns[i].text}`
94
+ if (used + line.length > budget) {
95
+ if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
96
+ line = line.slice(0, budget) // newest turn alone overflows → clip, still include
97
+ }
98
+ kept.push(line)
99
+ used += line.length
100
+ }
101
+ kept.reverse()
102
+ const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
103
+ const frame = FRAMING[reason] || FRAMING.switch
104
+ return [
105
+ frame.header,
106
+ frame.body,
107
+ '',
108
+ '--- conversation so far (oldest → newest) ---',
109
+ body,
110
+ '--- end recap ---',
111
+ ].join('\n')
112
+ }