thinkpool-pair 0.7.286 → 0.7.288

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
@@ -437,6 +437,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
437
437
  const POST_BUCKET = { max: 6, windowMs: 60_000 } // ≤6 inbound cross-room posts / partner / minute
438
438
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
439
439
  let pendingUpdate = null // newest published version once the poll sees it
440
+ let applyingUpdate = false
440
441
  const warned = new Set()
441
442
  const refused = new Map() // room -> reason ('home'|'none'|'dir-gone'): owned but NOT served, announced in presence so the web shows a precise "attach this session" card instead of spinning
442
443
 
@@ -1126,10 +1127,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1126
1127
  // Restart the account bridge to apply a pending update ONLY when it's safe: every
1127
1128
  // child idle (between turns) AND either a user clicked apply OR nobody's watching
1128
1129
  // (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
1129
- const applyIfIdle = () => {
1130
- if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
1130
+ const applyIfIdle = async () => {
1131
+ if (applyingUpdate || !isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
1132
+ applyingUpdate = true
1131
1133
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
1132
- stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
1134
+ try {
1135
+ const svc = await import('./service.mjs')
1136
+ if (svc.serviceActive(null) && svc.updateService(null) !== false) return
1137
+ } catch { /* fall back to the legacy supervised restart below */ }
1138
+ applyingUpdate = false
1139
+ stop('SIGTERM')
1133
1140
  }
1134
1141
 
1135
1142
  // ── auto-update (account-service tier only) ───────────────────────────────
package/bridge.mjs CHANGED
@@ -114,6 +114,7 @@ const flowRedispatch = new Map()
114
114
  // broadcasts; without persistent state the cap can never bite.
115
115
  const flowBudgets = new Map()
116
116
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
117
+ import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
117
118
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
118
119
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
119
120
  import { turnInFlight } from './update-gate.mjs'
@@ -1008,6 +1009,7 @@ const markActivity = () => { lastActivity = Date.now() }
1008
1009
  // ready" chip; the restart is gated to between turns (never mid-turn, Contract #1).
1009
1010
  let pendingUpdate = null
1010
1011
  let applyRequested = false // a user clicked "apply" in the room
1012
+ let managedUpdateRunning = false
1011
1013
  // A web client (not another bridge) is present — checks presence metas for a
1012
1014
  // non-bridge role. Gates the unattended auto-restart: when someone's watching we
1013
1015
  // wait for their apply click instead of restarting under them.
@@ -1186,6 +1188,7 @@ const announce = () => {
1186
1188
  // Spec: docs/specs/2026-06-30-cross-room-ensemble.md
1187
1189
  const IS_ACCOUNT_CHILD = !!(process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1')
1188
1190
  const pairWaiters = new Map() // reqId -> { resolve, timer } for our OUTBOUND requests
1191
+ let standalonePairChannel = null // standalone owner bridge's direct paired-session responder
1189
1192
  // Send a cross-room request to the supervisor and resolve with its reply ({ rooms }
1190
1193
  // or { text } / { error }). Fails soft (never throws into the tool handler): a
1191
1194
  // standalone bridge (no supervisor) or a disabled/timed-out request returns { error }.
@@ -1267,6 +1270,51 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
1267
1270
  return { ok: true, ref: String(targetId).slice(0, 8) }
1268
1271
  }
1269
1272
 
1273
+ // A room can be legitimately live without the account supervisor: `npx
1274
+ // thinkpool-pair <ROOM>` and room-specific services run bridge.mjs directly. The
1275
+ // old Tier-2 route listened only in account.mjs, so those rooms appeared live in
1276
+ // the dashboard (`tpcode:<room>`) but falsely offline to list/read/post_session.
1277
+ // Join the SAME private, RLS-gated pair bus here when this process is standalone
1278
+ // and owns a real two-person room. The target bridge then answers for this room
1279
+ // directly and reuses receiveCrossRoomPost(), including the mandatory red receipt
1280
+ // card. Account children skip this path to avoid duplicate replies/cards.
1281
+ async function startStandalonePairResponder () {
1282
+ if (IS_ACCOUNT_CHILD || process.env.TP_PAIRBUS_OFF === '1' || process.env.TP_CROSSROOM_OFF === '1') return
1283
+ if (!codeAuthToken || !myServeUid) return
1284
+ let row = null
1285
+ try {
1286
+ const url = `${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&select=code,name,owner_id,participants`
1287
+ const res = await fetch(url, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` } })
1288
+ if (!res.ok) return
1289
+ const rows = await res.json().catch(() => [])
1290
+ row = Array.isArray(rows) ? rows[0] : null
1291
+ } catch { return }
1292
+ const identity = standalonePairIdentity({ row, myUid: myServeUid })
1293
+ if (!identity?.topic) return
1294
+
1295
+ const responder = createStandalonePairResponder({
1296
+ room,
1297
+ roomName: row?.name || null,
1298
+ host: os.hostname().replace(/\.local$/, ''),
1299
+ formatPeek: (payload) => formatPeek({ selfId: null, sessions, terms, names: termNames, terminal: payload.terminal, lines: payload.lines }),
1300
+ receivePost: (payload) => receiveCrossRoomPost(payload),
1301
+ })
1302
+ const ch = supabase.channel(identity.topic, { config: { private: true, broadcast: { self: false } } })
1303
+ const reply = async (event, payload) => {
1304
+ if (!payload) return
1305
+ try { await ch.send({ type: 'broadcast', event, payload }) } catch { /* bus reconnect handles the next request */ }
1306
+ }
1307
+ ch.on('broadcast', { event: 'peer-list-req' }, ({ payload }) => { void reply('peer-list-res', responder.list(payload)) })
1308
+ .on('broadcast', { event: 'peer-peek-req' }, ({ payload }) => { const out = responder.peek(payload); if (out) void reply('peer-peek-res', out) })
1309
+ .on('broadcast', { event: 'peer-post-req' }, ({ payload }) => { void responder.post(payload).then((out) => { if (out) return reply('peer-post-res', out) }) })
1310
+ .subscribe((status) => {
1311
+ if (status === 'SUBSCRIBED') process.stderr.write(`\n ◆ standalone pair bus subscribed — this live room can receive approval-gated cross-session tasks.\n`)
1312
+ else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') process.stderr.write(`\n ◇ standalone pair bus ${status.toLowerCase().replace('_', ' ')} — cross-session reach will retry with realtime.\n`)
1313
+ })
1314
+ standalonePairChannel = ch
1315
+ }
1316
+ void startStandalonePairResponder()
1317
+
1270
1318
  if (IS_ACCOUNT_CHILD) {
1271
1319
  // A SECOND message listener (Node allows many) dedicated to pair traffic — leaves
1272
1320
  // the account-child idle/token listener (bottom of file) untouched.
@@ -3419,6 +3467,36 @@ function surfaceUpdate(version) {
3419
3467
  announce()
3420
3468
  }
3421
3469
 
3470
+ // A pinned managed room must stage the published runtime before it restarts.
3471
+ // Exiting and trusting launchd/systemd to "pick up latest" only reloads the same
3472
+ // immutable version. This is the room-scoped counterpart to account.mjs's
3473
+ // dashboard updater and is deliberately shared by the session and dashboard UI.
3474
+ const betweenUpdateTurns = () => {
3475
+ if (turnInFlight(sessions)) return false
3476
+ for (const t of terms.values()) if (t.buf) return false
3477
+ return true
3478
+ }
3479
+
3480
+ async function applyStandaloneManagedUpdate() {
3481
+ if (!applyRequested || !pendingUpdate || managedUpdateRunning) return false
3482
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') return false
3483
+ if (!betweenUpdateTurns()) return false
3484
+ managedUpdateRunning = true
3485
+ try {
3486
+ const svc = await import('./service.mjs')
3487
+ if (!svc.serviceActive(room)) return false
3488
+ const ok = svc.updateService(room) !== false
3489
+ if (!ok) managedUpdateRunning = false
3490
+ return ok
3491
+ } catch (error) {
3492
+ managedUpdateRunning = false
3493
+ process.stderr.write(`\n ⚠ bridge update failed: ${error?.message || error}\n`)
3494
+ return false
3495
+ }
3496
+ }
3497
+
3498
+ setInterval(() => { if (applyRequested) void applyStandaloneManagedUpdate() }, 5000).unref()
3499
+
3422
3500
  // After the attached CLI exits, the host's stdin stops feeding a PTY —
3423
3501
  // restore the cooked terminal so Ctrl-C reaches the bridge itself.
3424
3502
  const detachLocal = () => {
@@ -4069,6 +4147,42 @@ channel
4069
4147
  try { process.send({ t: 'apply-update' }) } catch { /* parent gone */ }
4070
4148
  }
4071
4149
  })
4150
+ // Authenticated + acknowledged update contract used by both the room card and
4151
+ // the dashboard's room-scoped fallback. Unlike the legacy apply-update event,
4152
+ // this does not depend on the bridge having discovered npm first: the web names
4153
+ // the advertised target, the host updater independently resolves npm latest,
4154
+ // and only a managed service is allowed to accept the operation.
4155
+ .on('broadcast', { event: 'bridge-update' }, async ({ payload }) => {
4156
+ const nonce = payload?.nonce
4157
+ const reply = (ok, error, state) => channel.send({
4158
+ type: 'broadcast', event: 'bridge-update-res',
4159
+ payload: { nonce, ok, ...(error ? { error } : {}), ...(state ? { state } : {}) },
4160
+ })
4161
+ if (!nonce || !(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
4162
+ const target = typeof payload?.v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(payload.v)
4163
+ ? payload.v : null
4164
+ if (!target) return reply(false, 'invalid bridge version')
4165
+
4166
+ surfaceUpdate(target)
4167
+ applyRequested = true
4168
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4169
+ try { process.send({ t: 'apply-update' }) } catch { return reply(false, 'account supervisor is unavailable') }
4170
+ return reply(true, null, 'queued')
4171
+ }
4172
+
4173
+ let managed = false
4174
+ try {
4175
+ const svc = await import('./service.mjs')
4176
+ managed = svc.serviceActive(room)
4177
+ } catch { /* reported as an explicit foreground refusal below */ }
4178
+ if (!managed) {
4179
+ applyRequested = false
4180
+ return reply(false, 'This bridge is running in a terminal. Restart it once with npx thinkpool-pair@latest, or install the background service to enable remote updates.')
4181
+ }
4182
+ if (!betweenUpdateTurns()) return reply(true, null, 'queued')
4183
+ await reply(true, null, 'applying')
4184
+ void applyStandaloneManagedUpdate()
4185
+ })
4072
4186
  // Persist + re-announce terminal renames. The web also echoes term-rename to
4073
4187
  // online peers directly; storing it here is what reaches a device that joins
4074
4188
  // LATER (or a second machine) — those only ever see the announce.
@@ -4833,6 +4947,7 @@ async function shutdown(code = 0, farewell = true) {
4833
4947
  // socket is alive; on a wedge it won't flush and the hard backstop above wins).
4834
4948
  try { await channel.untrack() } catch { /* noop */ }
4835
4949
  try { await supabase.removeChannel(channel) } catch { /* noop */ }
4950
+ try { if (standalonePairChannel) await supabase.removeChannel(standalonePairChannel) } catch { /* noop */ }
4836
4951
  // BRG-2 (audit 2026-07-02): shutdown removed only the room channel, leaking the flow
4837
4952
  // topic (no presence to untrack, but the subscription + socket join outlived the
4838
4953
  // process teardown). Close it too so shutdown is symmetric.
@@ -720,7 +720,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
720
720
  '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.',
721
721
  'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Collect each worker with read_terminal and close_terminal immediately after using its result. Main conductors are independent terminals, keep their requested permission mode, and are not owned/closed through Ensemble.',
722
722
  '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.',
723
- '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.',
723
+ '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. Outbound list/read/post tools need the ThinkPool account bridge; a standalone owner room can still receive a paired hand-off directly and will always raise its own approval card before delivery.',
724
724
  'SUBAGENT POLICY: in this room, a main conductor delegates worker slices through visible spawn_terminal Ensemble lanes. A requested separate conductor is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
725
725
  'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',
726
726
  'PEER FIRST: other lanes may be working in the same repo as you, right now. Before starting substantive work — and before any code edit that could overlap another lane — check what the room is doing: the ROOM NOW snapshot appended to your latest turn, or read_terminal for detail; list_sessions/read_session when the question spans your other rooms. If a sibling is touching the same files or branch, coordinate (read its lane, or raise it in chat) instead of colliding.',
@@ -0,0 +1,57 @@
1
+ import { pairKeyFor, pairTopic } from './cross-terminal.mjs'
2
+
3
+ // A standalone room bridge is already the authoritative process for its room. It
4
+ // must be reachable over the pair bus even when the account-level supervisor is
5
+ // not running. Keep the identity and request handling in a small tested module so
6
+ // the standalone path preserves the same pair-only and bounded-post invariants as
7
+ // account.mjs.
8
+
9
+ export function standalonePairIdentity ({ row, myUid } = {}) {
10
+ const participants = Array.isArray(row?.participants) ? row.participants.map(String) : []
11
+ const me = String(myUid || '')
12
+ if (!me || String(row?.owner_id || '') !== me || participants.length !== 2 || !participants.includes(me)) return null
13
+ const partnerUid = participants.find((uid) => uid !== me)
14
+ const key = pairKeyFor(me, partnerUid)
15
+ if (!partnerUid || !key) return null
16
+ return { partnerUid, key, topic: pairTopic(key) }
17
+ }
18
+
19
+ export function createStandalonePairResponder ({
20
+ room,
21
+ roomName = null,
22
+ host = null,
23
+ formatPeek,
24
+ receivePost,
25
+ now = () => Date.now(),
26
+ postLimit = 6,
27
+ postWindowMs = 60_000,
28
+ } = {}) {
29
+ if (!room || typeof formatPeek !== 'function' || typeof receivePost !== 'function') {
30
+ throw new TypeError('room, formatPeek, and receivePost are required')
31
+ }
32
+ let bucket = { count: 0, resetAt: 0 }
33
+
34
+ const isTarget = (payload) => String(payload?.targetRoom || '').toUpperCase().trim() === room
35
+ const allowPost = () => {
36
+ const t = now()
37
+ if (t >= bucket.resetAt) bucket = { count: 0, resetAt: t + postWindowMs }
38
+ if (bucket.count >= postLimit) return false
39
+ bucket.count += 1
40
+ return true
41
+ }
42
+
43
+ return Object.freeze({
44
+ list: (payload) => ({ nonce: payload?.nonce, rooms: [{ code: room, name: roomName, host }] }),
45
+ peek: (payload) => {
46
+ if (!isTarget(payload)) return null
47
+ try { return { nonce: payload?.nonce, text: formatPeek(payload) } }
48
+ catch { return { nonce: payload?.nonce, error: 'Could not read that session.' } }
49
+ },
50
+ post: async (payload) => {
51
+ if (!isTarget(payload)) return null
52
+ if (!allowPost()) return { nonce: payload?.nonce, error: 'Too many cross-room posts to this device right now — try again shortly.' }
53
+ try { return { nonce: payload?.nonce, ...(await receivePost(payload)) } }
54
+ catch { return { nonce: payload?.nonce, error: `Could not deliver to room ${room}.` } }
55
+ },
56
+ })
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.286",
3
+ "version": "0.7.288",
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": {
@@ -56,6 +56,7 @@
56
56
  "side-lane.mjs",
57
57
  "cross-terminal.mjs",
58
58
  "pair-bus.mjs",
59
+ "direct-pair-room.mjs",
59
60
  "lane-lifecycle.mjs",
60
61
  "interrupted-resume.mjs",
61
62
  "dispatch-lease.mjs",