thinkpool-pair 0.7.318 → 0.7.320
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/README.md +6 -6
- package/account.mjs +32 -18
- package/bridge.mjs +6 -5
- package/command-guidance.mjs +8 -0
- package/package.json +2 -1
- package/presence.mjs +34 -0
- package/provider.mjs +5 -4
- package/serve-consent.mjs +3 -1
- package/service.mjs +3 -2
package/README.md
CHANGED
|
@@ -31,14 +31,14 @@ The bridge self-heals at three levels — all cross-platform (macOS / Linux / Wi
|
|
|
31
31
|
backoff). Zero-dependency. Combined with session restore (below), a crash is
|
|
32
32
|
invisible — the room reconnects and the Claude session resumes where it left off.
|
|
33
33
|
```bash
|
|
34
|
-
npx thinkpool-pair <ROOM> --supervise -- claude
|
|
34
|
+
npx thinkpool-pair@latest <ROOM> --supervise -- claude
|
|
35
35
|
```
|
|
36
36
|
- **Boot-persistent service** — survive reboot/logout too. Installs the right
|
|
37
37
|
native service for your OS (launchd on macOS, systemd `--user` on Linux, a
|
|
38
38
|
Startup-folder script on Windows):
|
|
39
39
|
```bash
|
|
40
|
-
npx thinkpool-pair install-service <ROOM> -- claude # set and forget
|
|
41
|
-
npx thinkpool-pair uninstall-service <ROOM> # remove it
|
|
40
|
+
npx thinkpool-pair@latest install-service <ROOM> -- claude # set and forget
|
|
41
|
+
npx thinkpool-pair@latest uninstall-service <ROOM> # remove it
|
|
42
42
|
```
|
|
43
43
|
The service runs `npx thinkpool-pair@latest`, so it **auto-updates** — new
|
|
44
44
|
versions apply on the next restart, no re-install. (Linux: run
|
|
@@ -186,7 +186,7 @@ that format:
|
|
|
186
186
|
> not OpenRouter's Anthropic endpoint.
|
|
187
187
|
|
|
188
188
|
```bash
|
|
189
|
-
npx thinkpool-pair provider custom --base <url> --token <key> [--model <name>]
|
|
189
|
+
npx thinkpool-pair@latest provider custom --base <url> --token <key> [--model <name>]
|
|
190
190
|
```
|
|
191
191
|
|
|
192
192
|
Common base urls (the agent appends `/v1/messages`):
|
|
@@ -203,11 +203,11 @@ Common base urls (the agent appends `/v1/messages`):
|
|
|
203
203
|
> gateway is for. Avoid LiteLLM 1.82.7 / 1.82.8 (compromised releases); use a
|
|
204
204
|
> current version.
|
|
205
205
|
|
|
206
|
-
Show the current provider (`npx thinkpool-pair provider`), or reset back to your
|
|
206
|
+
Show the current provider (`npx thinkpool-pair@latest provider`), or reset back to your
|
|
207
207
|
regular Claude login:
|
|
208
208
|
|
|
209
209
|
```bash
|
|
210
|
-
npx thinkpool-pair provider anthropic
|
|
210
|
+
npx thinkpool-pair@latest provider anthropic
|
|
211
211
|
```
|
|
212
212
|
|
|
213
213
|
Provider config is stored in `~/.thinkpool-pair/provider.json` (mode 0600) and
|
package/account.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
15
15
|
import os from 'node:os'
|
|
16
16
|
import { saveAuth, loadAuth, markAuthReconnectRequired, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
|
-
import { makeThrottledTrack, presenceSelfEchoObservation } from './presence.mjs'
|
|
18
|
+
import { makeThrottledTrack, presenceRecoveryEligible, presenceSelfEchoObservation, reducePresenceChannelEvidence } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
|
|
20
20
|
import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
@@ -24,6 +24,7 @@ import { installedAgentCommands } from './agent-detect.mjs'
|
|
|
24
24
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
25
25
|
import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
|
|
26
26
|
import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
|
|
27
|
+
import { PAIR_CLI, pairCli } from './command-guidance.mjs'
|
|
27
28
|
|
|
28
29
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
29
30
|
|
|
@@ -121,7 +122,7 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
|
|
|
121
122
|
process.stderr.write(`\n ◆ Link this device to your ThinkPool account.\n Open (signed in) and approve:\n\n ${url}\n\n code: ${user_code}\n\n Waiting…\n`)
|
|
122
123
|
const started = Date.now()
|
|
123
124
|
const poll = async () => {
|
|
124
|
-
if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write(
|
|
125
|
+
if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write(`\n ◇ link timed out — run \`${pairCli('login')}\` again.\n`); process.exit(1) }
|
|
125
126
|
try {
|
|
126
127
|
// A discarded `error` costs one tick — the loop retries in a second, and a
|
|
127
128
|
// permanent failure still lands on the "link timed out" branch above rather than
|
|
@@ -130,10 +131,10 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
|
|
|
130
131
|
const { data: r } = await sb.rpc('claim_device_code', { p_device_code: device_code })
|
|
131
132
|
if (r?.status === 'approved' && r.session?.refresh_token) {
|
|
132
133
|
saveAuth(r.session)
|
|
133
|
-
process.stderr.write(`\n ◆ linked as ${r.session.email || 'your account'}. You can close the browser tab.\n Now run:
|
|
134
|
+
process.stderr.write(`\n ◆ linked as ${r.session.email || 'your account'}. You can close the browser tab.\n Now run: ${PAIR_CLI}\n`)
|
|
134
135
|
process.exit(0)
|
|
135
136
|
}
|
|
136
|
-
if (r?.status === 'expired' || r?.status === 'not_found') { process.stderr.write(
|
|
137
|
+
if (r?.status === 'expired' || r?.status === 'not_found') { process.stderr.write(`\n ◇ link expired — run \`${pairCli('login')}\` again.\n`); process.exit(1) }
|
|
137
138
|
} catch { /* transient — keep polling */ }
|
|
138
139
|
setTimeout(poll, 3000)
|
|
139
140
|
}
|
|
@@ -397,18 +398,18 @@ export function scheduleTokenRefresh({ sb, session, onRefreshed, onReconnectRequ
|
|
|
397
398
|
|
|
398
399
|
export function runBind(room, dir) {
|
|
399
400
|
const r = (room || '').toUpperCase().trim()
|
|
400
|
-
if (!r) { console.error(
|
|
401
|
+
if (!r) { console.error(`usage: ${pairCli('bind', '<ROOM>', '<directory>')}`); process.exit(1) }
|
|
401
402
|
const abs = path.resolve(dir || '.')
|
|
402
403
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { console.error(`✗ not a directory: ${abs}`); process.exit(1) }
|
|
403
404
|
bindDir(r, abs)
|
|
404
|
-
console.error(`✓ bound ${r} → ${abs}\n Run \`
|
|
405
|
+
console.error(`✓ bound ${r} → ${abs}\n Run \`${PAIR_CLI}\` (or restart it) to serve this session.`)
|
|
405
406
|
process.exit(0)
|
|
406
407
|
}
|
|
407
408
|
|
|
408
409
|
// ── account supervisor: a headless child bridge per bound room, in its dir ──
|
|
409
410
|
export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
410
411
|
if (!acquireSingleton()) {
|
|
411
|
-
console.error(
|
|
412
|
+
console.error(`\n ◇ An account bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Not starting a second — two would race\n your saved login. Stop the other one first, or share a single room with\n \`${pairCli('<ROOM>')}\`.\n`)
|
|
412
413
|
process.exit(0)
|
|
413
414
|
}
|
|
414
415
|
// We own account.lock now, so any previous readiness receipt is stale. A fresh
|
|
@@ -430,7 +431,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
430
431
|
// Reached ONLY when there is no saved login at all (not linked). Stand down CLEANLY
|
|
431
432
|
// with exit 0: launchd KeepAlive has SuccessfulExit=false, so exit 0 is NOT respawned
|
|
432
433
|
// — no crash storm for the "never logged in" case. The user links with `login`.
|
|
433
|
-
console.error(
|
|
434
|
+
console.error(`\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: ${pairCli('login')}\n`)
|
|
434
435
|
process.exit(0)
|
|
435
436
|
}
|
|
436
437
|
if (auth.reconnectRequired) {
|
|
@@ -470,9 +471,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
470
471
|
? ' (auto-updating background service — survives reboot, tracks @latest)\n'
|
|
471
472
|
: isManagedService
|
|
472
473
|
? ' (background service — survives reboot, restarts on crash, pinned to this version)\n'
|
|
473
|
-
:
|
|
474
|
+
: ` (run hands-off + auto-updating: ${pairCli('install-service')})\n`
|
|
474
475
|
const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
|
|
475
|
-
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it:
|
|
476
|
+
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: ${pairCli('set-default-dir', '<dir>')} · one room: ${pairCli('bind', '<code>', '<dir>')})\n${svcHint}`)
|
|
476
477
|
|
|
477
478
|
const children = new Map() // room -> child process
|
|
478
479
|
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
@@ -679,8 +680,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
679
680
|
// and stamps presenceSyncSeenAt. acctSubscribedAt bounds the startup case where
|
|
680
681
|
// SUBSCRIBED fires but the first sync never arrives: that is a broken Realtime
|
|
681
682
|
// channel, not an unknowable state, once the listener was attached before join.
|
|
682
|
-
let
|
|
683
|
-
let acctSubscribedAt = 0
|
|
683
|
+
let presenceEvidence = reducePresenceChannelEvidence()
|
|
684
684
|
let initialTickDone = false
|
|
685
685
|
let readinessWritten = false
|
|
686
686
|
const publishReadiness = () => {
|
|
@@ -691,14 +691,24 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
691
691
|
readinessWritten = writeSupervisorReady({ version: VERSION, bridgeId: BRIDGE_ID })
|
|
692
692
|
if (!readinessWritten) process.stderr.write('\n ◇ Could not record local supervisor readiness; the bridge remains running but installers will not claim it was confirmed.\n')
|
|
693
693
|
}
|
|
694
|
-
acct.on('presence', { event: 'sync' }, () => {
|
|
694
|
+
acct.on('presence', { event: 'sync' }, () => {
|
|
695
|
+
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, 'sync')
|
|
696
|
+
publishReadiness()
|
|
697
|
+
})
|
|
695
698
|
|
|
696
699
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
697
700
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
698
701
|
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
|
699
702
|
await new Promise((res) => acct.subscribe((st) => {
|
|
700
|
-
if (st === 'SUBSCRIBED') {
|
|
703
|
+
if (st === 'SUBSCRIBED') {
|
|
704
|
+
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
|
|
705
|
+
pushPresence(); res()
|
|
706
|
+
}
|
|
701
707
|
else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
|
708
|
+
// Invalidate the old generation before inspecting presenceState again. The
|
|
709
|
+
// channel retains a local self key across failures; accepting that cache is
|
|
710
|
+
// what left the managed service zombified while a fresh foreground run worked.
|
|
711
|
+
presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
|
|
702
712
|
// Realtime dropped (the 2026-07-08 `realtime CLOSED` blip). The socket normally
|
|
703
713
|
// auto-reconnects, but nudge it so presence — and the claim heartbeat that shares
|
|
704
714
|
// this client — recover instead of the loop standing by against its own stale row.
|
|
@@ -946,7 +956,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
946
956
|
const why = choice.reason === 'home'
|
|
947
957
|
? `no project directory set — refusing to serve it from your home folder (${choice.dir}); the agent would lose its session context and the room would show "${path.basename(choice.dir)}" as the repo.`
|
|
948
958
|
: 'no directory to serve it from.'
|
|
949
|
-
process.stderr.write(`\n ◇ ${room}: ${why}\n Bind it to its repo:
|
|
959
|
+
process.stderr.write(`\n ◇ ${room}: ${why}\n Bind it to its repo: ${pairCli('bind', room, '<repo-dir>')}\n`)
|
|
950
960
|
}
|
|
951
961
|
continue
|
|
952
962
|
}
|
|
@@ -1177,12 +1187,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
1177
1187
|
const presenceWatch = setInterval(() => {
|
|
1178
1188
|
if (stopping) return
|
|
1179
1189
|
const now = Date.now()
|
|
1190
|
+
const recoveryEligible = presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now })
|
|
1180
1191
|
let present = false
|
|
1181
1192
|
try { present = !!acct.presenceState?.()[machine] } catch { present = false }
|
|
1182
1193
|
const observation = presenceSelfEchoObservation({
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1194
|
+
// A fresh HTTP claim distinguishes a Realtime-only zombie from a full
|
|
1195
|
+
// connectivity outage. In the latter case, wait in-process; a respawn cannot
|
|
1196
|
+
// restore the network and launchd would otherwise loop indefinitely.
|
|
1197
|
+
tracking: recoveryEligible,
|
|
1198
|
+
subscribedAt: Math.max(presenceEvidence.subscribedAt, lastClaimOkAt),
|
|
1199
|
+
syncSeenAt: presenceEvidence.syncSeenAt,
|
|
1186
1200
|
present,
|
|
1187
1201
|
missingSince: selfEchoMissingSince,
|
|
1188
1202
|
now,
|
package/bridge.mjs
CHANGED
|
@@ -68,6 +68,7 @@ import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
|
|
|
68
68
|
import { hermesUserInputResponse } from './question-response.mjs'
|
|
69
69
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
70
70
|
import { queueAbortBarrier, waitForAbortBarrier } from './abort-turn-barrier.mjs'
|
|
71
|
+
import { PAIR_CLI, pairCli } from './command-guidance.mjs'
|
|
71
72
|
|
|
72
73
|
const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
|
|
73
74
|
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
|
|
@@ -292,7 +293,7 @@ if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); r
|
|
|
292
293
|
if (argv[0] === 'set-default-dir') {
|
|
293
294
|
const { saveDefaultDir } = await import('./auth-store.mjs')
|
|
294
295
|
let d = (argv[1] || '').trim(); if (d.startsWith('~')) d = d.replace(/^~/, os.homedir())
|
|
295
|
-
if (!d) { console.error(
|
|
296
|
+
if (!d) { console.error(` usage: ${pairCli('set-default-dir', '<dir>')}`); process.exit(1) }
|
|
296
297
|
saveDefaultDir(d)
|
|
297
298
|
console.log(` ◆ default project dir set → ${d}\n New/unbound sessions auto-serve from here — no per-session bind. Takes effect next serve tick.`)
|
|
298
299
|
process.exit(0)
|
|
@@ -437,7 +438,7 @@ function checkSdkCompat() {
|
|
|
437
438
|
if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
|
|
438
439
|
|
|
439
440
|
const room = (argv[0] || '').toUpperCase().trim()
|
|
440
|
-
if (!room) { console.error(
|
|
441
|
+
if (!room) { console.error(`usage: ${pairCli('<ROOM>', '[--headless]', '[--continue|--fresh]', '[-- <command…>]')} | ${PAIR_CLI} (account mode)`); process.exit(1) }
|
|
441
442
|
// Security (audit 2026-07-02): `room` becomes a filesystem path segment under
|
|
442
443
|
// ~/.thinkpool-pair (session-store) AND is interpolated into launchctl/systemd
|
|
443
444
|
// service labels. sessions.id is unconstrained TEXT, so refuse path-unsafe / shell-
|
|
@@ -549,7 +550,7 @@ if (dashIdx >= 0) {
|
|
|
549
550
|
}
|
|
550
551
|
} else if (!headless) {
|
|
551
552
|
if (installedAgents.length === 0) {
|
|
552
|
-
console.error(`\n No known coding-agent CLI found on your PATH.\n Install one (claude / codex / gemini / aider / cursor-agent / opencode …)\n or share a specific command:
|
|
553
|
+
console.error(`\n No known coding-agent CLI found on your PATH.\n Install one (claude / codex / gemini / aider / cursor-agent / opencode …)\n or share a specific command: ${pairCli(room, '--', '<your-command>')}\n`)
|
|
553
554
|
process.exit(1)
|
|
554
555
|
}
|
|
555
556
|
attachedCmd = await pickAgent(installedAgents)
|
|
@@ -831,7 +832,7 @@ let myServeUid = null // this bridge's authed uid (null = anon)
|
|
|
831
832
|
// 401 must NEVER serve, or the gate is bypassed (DEFECT 1). gateFailAction classifies.
|
|
832
833
|
if (gateFailAction(e) === 'refuse') {
|
|
833
834
|
process.stderr.write(refusalMessage({ room, name: null, reason: 'no-identity' }))
|
|
834
|
-
process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run:
|
|
835
|
+
process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run: ${pairCli('login')})\n`)
|
|
835
836
|
process.exit(0)
|
|
836
837
|
}
|
|
837
838
|
// Availability failure → fail OPEN (spec: never strand a user on a flaky fetch).
|
|
@@ -1402,7 +1403,7 @@ let standalonePairChannel = null // standalone owner bridge's direct paired-sess
|
|
|
1402
1403
|
// standalone bridge (no supervisor) or a disabled/timed-out request returns { error }.
|
|
1403
1404
|
function pairRequest(type, payload = {}, timeoutMs = 4000) {
|
|
1404
1405
|
if (process.env.TP_CROSSROOM_OFF === '1') return Promise.resolve({ error: 'Cross-session reach is turned off in this room.' })
|
|
1405
|
-
if (!IS_ACCOUNT_CHILD) return Promise.resolve({ error:
|
|
1406
|
+
if (!IS_ACCOUNT_CHILD) return Promise.resolve({ error: `Cross-session reach needs the ThinkPool account bridge running your sessions (${PAIR_CLI}). This room is running standalone, so it can only see its own terminals — use read_terminal.` })
|
|
1406
1407
|
const reqId = randomUUID()
|
|
1407
1408
|
return new Promise((resolve) => {
|
|
1408
1409
|
const timer = setTimeout(() => { pairWaiters.delete(reqId); resolve({ error: 'The other session did not respond in time.' }) }, timeoutMs)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Canonical, cache-busting invocation for every command printed to people.
|
|
2
|
+
export const PAIR_CLI = 'npx thinkpool-pair@latest'
|
|
3
|
+
|
|
4
|
+
export function pairCli(...args) {
|
|
5
|
+
return [PAIR_CLI, ...args.filter((arg) => arg !== undefined && arg !== null && String(arg).length > 0)]
|
|
6
|
+
.map(String)
|
|
7
|
+
.join(' ')
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.320",
|
|
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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bridge.mjs",
|
|
11
|
+
"command-guidance.mjs",
|
|
11
12
|
"abort-turn-barrier.mjs",
|
|
12
13
|
"host-memory.mjs",
|
|
13
14
|
"sdk-smoke.mjs",
|
package/presence.mjs
CHANGED
|
@@ -57,6 +57,40 @@ export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } =
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// A RealtimeChannel keeps its last presenceState() locally. After CHANNEL_ERROR /
|
|
61
|
+
// CLOSED that cache can still contain our own key even though the server (and every
|
|
62
|
+
// dashboard subscriber) has already removed it. Treat every subscribe/error edge as
|
|
63
|
+
// a new evidence generation: only a presence sync delivered in that generation may
|
|
64
|
+
// certify the cached self key.
|
|
65
|
+
export function reducePresenceChannelEvidence(state = {}, event, now = Date.now()) {
|
|
66
|
+
const current = {
|
|
67
|
+
subscribedAt: Number(state.subscribedAt) || 0,
|
|
68
|
+
syncSeenAt: Number(state.syncSeenAt) || 0,
|
|
69
|
+
}
|
|
70
|
+
if (event === 'sync') return { ...current, syncSeenAt: now }
|
|
71
|
+
if (event === 'SUBSCRIBED') {
|
|
72
|
+
// A reconnect loop may report SUBSCRIBED repeatedly without ever delivering
|
|
73
|
+
// presence sync. Preserve the first unsynced boundary so retries cannot keep
|
|
74
|
+
// pushing the watchdog deadline out forever.
|
|
75
|
+
if (current.subscribedAt && !current.syncSeenAt) return current
|
|
76
|
+
return { subscribedAt: now, syncSeenAt: 0 }
|
|
77
|
+
}
|
|
78
|
+
if (event === 'CLOSED' || event === 'CHANNEL_ERROR' || event === 'TIMED_OUT') {
|
|
79
|
+
// Likewise, repeated error callbacks are one failure episode until a fresh
|
|
80
|
+
// sync proves recovery.
|
|
81
|
+
if (current.subscribedAt && !current.syncSeenAt) return current
|
|
82
|
+
return { subscribedAt: now, syncSeenAt: 0 }
|
|
83
|
+
}
|
|
84
|
+
return current
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Recycling a websocket is useful only when plain HTTP is healthy enough to prove
|
|
88
|
+
// this is a Realtime-only wedge. During a full network/Supabase outage, respawning a
|
|
89
|
+
// launchd service cannot help and merely creates a restart storm.
|
|
90
|
+
export function presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now, maxClaimSilenceMs = 40_000 }) {
|
|
91
|
+
return !!claimHeld && Number.isFinite(lastClaimOkAt) && (now - lastClaimOkAt) < maxClaimSilenceMs
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
/* ─────────────────────────────────────────────────────────────
|
|
61
95
|
presenceSelfEchoVerdict — the pure trigger for the account
|
|
62
96
|
supervisor's presence self-echo watchdog (2026-07-08 recurrence).
|
package/provider.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import os from 'node:os'
|
|
32
32
|
import fs from 'node:fs'
|
|
33
33
|
import path from 'node:path'
|
|
34
|
+
import { pairCli } from './command-guidance.mjs'
|
|
34
35
|
|
|
35
36
|
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
36
37
|
const FILE = path.join(DIR, 'provider.json')
|
|
@@ -68,7 +69,7 @@ export function applyProviderEnv() {
|
|
|
68
69
|
const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
|
|
69
70
|
const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
|
|
70
71
|
if (!baseUrl || !authToken) {
|
|
71
|
-
process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup:
|
|
72
|
+
process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>')}\n Falling back to regular Claude for now.\n`)
|
|
72
73
|
return { provider: 'anthropic', source: 'incomplete' }
|
|
73
74
|
}
|
|
74
75
|
process.env.ANTHROPIC_BASE_URL = baseUrl
|
|
@@ -121,9 +122,9 @@ export async function runProvider(args) {
|
|
|
121
122
|
|
|
122
123
|
console.error(
|
|
123
124
|
'\n usage:\n' +
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
125
|
+
` ${pairCli('provider')} # show current\n` +
|
|
126
|
+
` ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>', '[--model <m>]')} # any Anthropic-compatible endpoint\n` +
|
|
127
|
+
` ${pairCli('provider', 'anthropic')} # reset to Claude\n\n` +
|
|
127
128
|
' common base urls (SDK appends /v1/messages):\n' +
|
|
128
129
|
' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
|
|
129
130
|
' OpenRouter → https://openrouter.ai/api\n'
|
package/serve-consent.mjs
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// so even an old bridge can no longer READ a grant (the column is gone) and will refuse
|
|
19
19
|
// as a non-owner. That closes the bypass.
|
|
20
20
|
|
|
21
|
+
import { pairCli } from './command-guidance.mjs'
|
|
22
|
+
|
|
21
23
|
// serveDecision — may THIS bridge serve THIS room?
|
|
22
24
|
// ownerId : code_sessions.owner_id (uuid | null)
|
|
23
25
|
// myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
|
|
@@ -109,7 +111,7 @@ export function refusalMessage ({ room, name, reason }) {
|
|
|
109
111
|
const label = name ? `"${name}"` : '(unnamed)'
|
|
110
112
|
if (reason === 'no-identity') {
|
|
111
113
|
return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
|
|
112
|
-
` A room is served only by its owner's bridge. Sign in first:
|
|
114
|
+
` A room is served only by its owner's bridge. Sign in first: ${pairCli('login')}\n`
|
|
113
115
|
}
|
|
114
116
|
return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; only their bridge serves it.\n` +
|
|
115
117
|
` Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`
|
package/service.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import path from 'node:path'
|
|
|
24
24
|
import { execSync } from 'node:child_process'
|
|
25
25
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
26
26
|
import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
|
|
27
|
+
import { pairCli } from './command-guidance.mjs'
|
|
27
28
|
|
|
28
29
|
// Service identity. Account mode has no room → a single stable id so there's
|
|
29
30
|
// exactly one account service per machine (a second install replaces it).
|
|
@@ -499,10 +500,10 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
|
|
|
499
500
|
? (process.platform === 'win32'
|
|
500
501
|
? 'auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
|
|
501
502
|
: `auto-update ON: checks npm ~every 30 min and restarts to apply a new @latest once sessions are idle (≥${idleSecs}s quiet; they resume in place).`)
|
|
502
|
-
: `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or
|
|
503
|
+
: `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or run ${pairCli('install-service', room || undefined)}). Pass --auto-update to track @latest instead.`
|
|
503
504
|
const removeArg = room ? ` ${room}` : ''
|
|
504
505
|
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
505
|
-
process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with:
|
|
506
|
+
process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: ${pairCli('uninstall-service')}${removeArg}\n\n`)
|
|
506
507
|
return true
|
|
507
508
|
}
|
|
508
509
|
|