thinkpool-pair 0.7.294 → 0.7.296
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 +24 -7
- package/launcher.mjs +10 -1
- package/package.json +2 -1
- package/providers.mjs +8 -0
- package/terminal-row-reconcile.mjs +65 -0
package/bridge.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
39
39
|
import { createClient } from '@supabase/supabase-js'
|
|
40
40
|
import { serveDecision, fetchServeRow, refusalMessage, gateFailAction } from './serve-consent.mjs'
|
|
41
41
|
import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
|
|
42
|
+
import { reconcileTerminalRows as _reconcileTerminalRows } from './terminal-row-reconcile.mjs'
|
|
42
43
|
import { cancelDurableDispatchPermissions, cancelDurablePermissions } from './dispatch-permission-cleanup.mjs'
|
|
43
44
|
// Slice 3 — the pure decision layer for agent push events (turn-done / needs-input).
|
|
44
45
|
// Unit-tested in bridge/agent-notify.test.mjs; everything with I/O stays here.
|
|
@@ -46,7 +47,7 @@ import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummar
|
|
|
46
47
|
// resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
|
|
47
48
|
// registered custom provider, or null for the built-in/unknown (leave the default env intact).
|
|
48
49
|
// Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
|
|
49
|
-
import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
|
|
50
|
+
import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
|
|
50
51
|
import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
|
|
51
52
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
52
53
|
import { z } from 'zod'
|
|
@@ -348,9 +349,9 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
348
349
|
child.on('exit', (code) => process.exit(code == null ? 0 : code))
|
|
349
350
|
}),
|
|
350
351
|
serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
|
|
351
|
-
// install / uninstall
|
|
352
|
-
//
|
|
353
|
-
//
|
|
352
|
+
// install / uninstall are NOT terminal — they do their work, print their notes,
|
|
353
|
+
// and return to the menu. A confirmed update closes the launcher instead: its
|
|
354
|
+
// in-memory package version is stale while the new service runs independently.
|
|
354
355
|
installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
|
|
355
356
|
uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
|
|
356
357
|
restartService: ({ room = null } = {}) => { svc.restartService(room) },
|
|
@@ -595,6 +596,7 @@ const BRIDGE_STARTED_AT = Date.now()
|
|
|
595
596
|
// box. Distinct from `name` (the driver's username). Announced additively —
|
|
596
597
|
// top-level only (one bridge per announce); older clients ignore the unknown key.
|
|
597
598
|
const host = (os.hostname() || 'host').split('.')[0].slice(0, 24)
|
|
599
|
+
const hostId = bridgeHostId()
|
|
598
600
|
|
|
599
601
|
// Repo awareness — the room shows which project this machine is sharing.
|
|
600
602
|
// Cheap reads, no subprocess: directory name + .git/HEAD.
|
|
@@ -718,6 +720,20 @@ function reapTerminalRow(id) {
|
|
|
718
720
|
onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reap failed (${String(id).slice(0, 8)}${status ? `, HTTP ${status}` : ''}): ${error || 'unknown error'}\n`),
|
|
719
721
|
})
|
|
720
722
|
}
|
|
723
|
+
|
|
724
|
+
// Crash/abort counterpart to the clean-close reap above. Called only after the
|
|
725
|
+
// subscribed bridge has restored every on-disk session into `terms`/`sessions`.
|
|
726
|
+
// The stable host filter is load-bearing: another machine's dormant lanes are
|
|
727
|
+
// durable product state, while this machine's missing rows are stale ghosts.
|
|
728
|
+
function reconcileTerminalRows() {
|
|
729
|
+
return _reconcileTerminalRows({
|
|
730
|
+
room, hostId, liveIds: [...terms.keys(), ...sessions.keys()], token: codeAuthToken,
|
|
731
|
+
supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, shuttingDown,
|
|
732
|
+
onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reconcile failed${status ? ` (HTTP ${status})` : ''}: ${error || 'unknown error'}\n`),
|
|
733
|
+
}).then(({ reaped }) => {
|
|
734
|
+
if (reaped.length) process.stderr.write(`\n ◆ removed ${reaped.length} stale terminal row${reaped.length === 1 ? '' : 's'} after restore.\n`)
|
|
735
|
+
})
|
|
736
|
+
}
|
|
721
737
|
// Auth: is the sender a PARTICIPANT of THIS room (owner OR granted OR joined)? Room mode
|
|
722
738
|
// serves the room, so RLS returns participants; we confirm the jwt's user is one of them.
|
|
723
739
|
// Pattern (per #218/#219): getUser + uid → REST existence check on code_sessions
|
|
@@ -1130,9 +1146,9 @@ const announce = () => {
|
|
|
1130
1146
|
cwd, version: VERSION, promptBundle: THINKPOOL_PROMPT_BUNDLE,
|
|
1131
1147
|
// host: short machine label (see const `host`) so the room shows which box
|
|
1132
1148
|
// currently serves it + attributes dormant terminals to their home machine.
|
|
1133
|
-
// Additive top-level
|
|
1134
|
-
//
|
|
1135
|
-
host,
|
|
1149
|
+
// Additive top-level fields consumed by src/pages/code/room.jsx onAnnounce.
|
|
1150
|
+
// Older clients ignore them.
|
|
1151
|
+
host, hostId,
|
|
1136
1152
|
// uid: the authed user id whose bridge is serving this room (owner or grantee).
|
|
1137
1153
|
// Additive field (older clients ignore it). A grantee bridge watches peer `bridge`
|
|
1138
1154
|
// announces for uid === owner_id to know the owner's bridge reclaimed the room and
|
|
@@ -4460,6 +4476,7 @@ channel
|
|
|
4460
4476
|
}
|
|
4461
4477
|
}
|
|
4462
4478
|
await announce()
|
|
4479
|
+
void reconcileTerminalRows()
|
|
4463
4480
|
// Account presence must distinguish a spawned child from a usable room.
|
|
4464
4481
|
// Signal only after realtime subscribed, durable sessions restored, and the
|
|
4465
4482
|
// first authoritative roster was announced. The dashboard keeps the bridge
|
package/launcher.mjs
CHANGED
|
@@ -278,7 +278,16 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
278
278
|
if (pick.key === 'quit') return
|
|
279
279
|
if (pick.key === 'serve') { if (await ensureLoggedIn()) await actions.serveAccountForeground() }
|
|
280
280
|
else if (pick.key === 'service') { if (await ensureLoggedIn()) { await actions.installService({ room: null }); resync() } }
|
|
281
|
-
else if (pick.key === 'restart') {
|
|
281
|
+
else if (pick.key === 'restart') {
|
|
282
|
+
const updated = await actions.restartUpdateService({ room: null })
|
|
283
|
+
// The launcher process cannot update its own in-memory VERSION. Returning to
|
|
284
|
+
// the menu after a successful service update therefore showed the old launcher
|
|
285
|
+
// version beside the new managed-service version and ended on a misleading
|
|
286
|
+
// "press Enter" pause. The service is already independently running, so close
|
|
287
|
+
// this stale installer process once the OS-level update is confirmed.
|
|
288
|
+
if (updated === true) return
|
|
289
|
+
resync()
|
|
290
|
+
}
|
|
282
291
|
else if (pick.key === 'uninstall') { await actions.uninstallService({ room: null }); resync() }
|
|
283
292
|
else if (pick.key === 'settings') await settingsMenu()
|
|
284
293
|
await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.296",
|
|
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": {
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"serve-dir.mjs",
|
|
84
84
|
"serve-consent.mjs",
|
|
85
85
|
"reap-terminal.mjs",
|
|
86
|
+
"terminal-row-reconcile.mjs",
|
|
86
87
|
"switch-provider.mjs",
|
|
87
88
|
"service.mjs",
|
|
88
89
|
"presence.mjs",
|
package/providers.mjs
CHANGED
|
@@ -100,6 +100,14 @@ export function ensureKeypair() {
|
|
|
100
100
|
/** SPKI base64 public key for the announce (`providerPubKey`). */
|
|
101
101
|
export function publicKeyB64() { return ensureKeypair().publicKeyB64 }
|
|
102
102
|
|
|
103
|
+
// Stable, public machine identity for terminal ownership. `host` is a display
|
|
104
|
+
// label and can be "localhost" on multiple computers; this fingerprint is tied
|
|
105
|
+
// to the bridge's existing keypair without exposing private material. Accepting
|
|
106
|
+
// an explicit public key keeps the identity primitive directly testable.
|
|
107
|
+
export function bridgeHostId(pubKey = publicKeyB64()) {
|
|
108
|
+
return crypto.createHash('sha256').update(pubKey).digest('hex').slice(0, 32)
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
// ── seal / unseal (hybrid RSA-OAEP + AES-256-GCM) ───────────────────────
|
|
104
112
|
// seal() is here so the UNIT can drive both ends node-side; the real seal
|
|
105
113
|
// happens in the browser dashboard via WebCrypto against the same envelope.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* Reconcile durable terminal rows after a bridge has restored its on-disk roster.
|
|
2
|
+
|
|
3
|
+
A clean close is reaped by reap-terminal.mjs. This module closes the other
|
|
4
|
+
lifecycle boundary: a crash/abort can remove the live session file without a
|
|
5
|
+
clean-close event, leaving a permanent dormant DB row. A stable public host id
|
|
6
|
+
makes the bridge authoritative only for its own rows; foreign/null-host rows are
|
|
7
|
+
deliberately preserved for handover and mixed-version compatibility. */
|
|
8
|
+
|
|
9
|
+
const outcome = (extra = {}) => ({ reaped: [], ...extra })
|
|
10
|
+
|
|
11
|
+
export async function reconcileTerminalRows ({
|
|
12
|
+
room,
|
|
13
|
+
hostId,
|
|
14
|
+
liveIds = [],
|
|
15
|
+
token,
|
|
16
|
+
supabaseUrl,
|
|
17
|
+
anonKey,
|
|
18
|
+
shuttingDown = false,
|
|
19
|
+
fetchImpl = fetch,
|
|
20
|
+
onFailure,
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (shuttingDown) return outcome({ reason: 'shutdown' })
|
|
23
|
+
if (!token) return outcome({ reason: 'anon' })
|
|
24
|
+
if (!room) return outcome({ reason: 'no-room' })
|
|
25
|
+
if (!hostId) return outcome({ reason: 'no-host-id' })
|
|
26
|
+
|
|
27
|
+
const fail = (result) => {
|
|
28
|
+
try { onFailure?.(result) } catch { /* observability cannot break restore */ }
|
|
29
|
+
return outcome(result)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const query = `${supabaseUrl}/rest/v1/code_terminals?select=id,host_id` +
|
|
34
|
+
`&session_code=eq.${encodeURIComponent(room)}` +
|
|
35
|
+
`&host_id=eq.${encodeURIComponent(hostId)}`
|
|
36
|
+
const response = await fetchImpl(query, {
|
|
37
|
+
headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
|
|
38
|
+
})
|
|
39
|
+
if (!response?.ok) {
|
|
40
|
+
return fail({ reason: 'error', error: `list HTTP ${response?.status ?? 'unknown'}`, status: response?.status })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rows = await response.json()
|
|
44
|
+
const live = new Set(liveIds || [])
|
|
45
|
+
const stale = (Array.isArray(rows) ? rows : [])
|
|
46
|
+
.filter((row) => row?.id && row.host_id === hostId && !live.has(row.id))
|
|
47
|
+
.map((row) => row.id)
|
|
48
|
+
|
|
49
|
+
const reaped = []
|
|
50
|
+
for (const id of stale) {
|
|
51
|
+
const del = await fetchImpl(
|
|
52
|
+
`${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}` +
|
|
53
|
+
`&host_id=eq.${encodeURIComponent(hostId)}`,
|
|
54
|
+
{ method: 'DELETE', headers: { apikey: anonKey, Authorization: `Bearer ${token}` } },
|
|
55
|
+
)
|
|
56
|
+
if (!del?.ok) {
|
|
57
|
+
return fail({ reason: 'error', error: `delete HTTP ${del?.status ?? 'unknown'}`, status: del?.status, reaped })
|
|
58
|
+
}
|
|
59
|
+
reaped.push(id)
|
|
60
|
+
}
|
|
61
|
+
return { reason: 'ok', reaped }
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return fail({ reason: 'error', error: error?.message || String(error) })
|
|
64
|
+
}
|
|
65
|
+
}
|