thinkpool-pair 0.7.341 → 0.7.343
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 +17 -1
- package/agent-visibility.mjs +67 -0
- package/bridge.mjs +37 -2
- package/package.json +2 -1
- package/privacy-report.mjs +1 -1
package/account.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
|
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
22
22
|
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
23
23
|
import { installedAgentCommands } from './agent-detect.mjs'
|
|
24
|
+
import { agentVisibilityStates, setAgentPickerVisibility } from './agent-visibility.mjs'
|
|
24
25
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
25
26
|
import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
|
|
26
27
|
import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
|
|
@@ -639,7 +640,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
639
640
|
// to spawn against — re-read each announce so an add/remove reflects immediately.
|
|
640
641
|
// Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
|
|
641
642
|
const PROVIDER_PUBKEY = publicKeyB64()
|
|
642
|
-
const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands()
|
|
643
|
+
const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: agentVisibilityStates(installedAgentCommands()), accountAuthState, ts: Date.now() })
|
|
643
644
|
|
|
644
645
|
// Persist account-auth truth separately from process presence. Presence will vanish
|
|
645
646
|
// when the old JWT expires; the claim row keeps the reconnect instruction visible on
|
|
@@ -688,6 +689,21 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
688
689
|
const nonce = payload?.nonce
|
|
689
690
|
provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
|
|
690
691
|
})
|
|
692
|
+
// agent-visibility-set {cmd, enabled, nonce} changes only the web picker.
|
|
693
|
+
// The runtime stays installed and explicit runtime requests keep working.
|
|
694
|
+
acctControl.on('broadcast', { event: 'agent-visibility-set' }, async ({ payload }) => {
|
|
695
|
+
const nonce = payload?.nonce
|
|
696
|
+
const installed = installedAgentCommands()
|
|
697
|
+
const r = setAgentPickerVisibility(payload?.cmd, payload?.enabled === true, { installed })
|
|
698
|
+
if (r.ok) {
|
|
699
|
+
pushPresence()
|
|
700
|
+
for (const child of children.values()) {
|
|
701
|
+
try { child.send({ t: 'agent-visibility-changed' }) } catch { /* child may be exiting */ }
|
|
702
|
+
}
|
|
703
|
+
process.stderr.write(`\n ◆ terminal picker agents updated — ${r.agents.filter((agent) => agent.enabled).map((agent) => agent.cmd).join(', ')} visible.\n`)
|
|
704
|
+
}
|
|
705
|
+
provReply('agent-visibility-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, agents: r.ok ? r.agents : undefined })
|
|
706
|
+
})
|
|
691
707
|
|
|
692
708
|
// ENABLE presence delivery + the self-echo's ground truth. supabase-js only asks
|
|
693
709
|
// the server for presence state when a presence callback is bound at join time —
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Host-local picker visibility for installed coding agents.
|
|
2
|
+
//
|
|
3
|
+
// Installation remains detection truth: hiding a runtime does not uninstall it
|
|
4
|
+
// or prevent an explicit bridge command from opening it. This preference only
|
|
5
|
+
// controls which installed runtimes the web offers in human-facing terminal
|
|
6
|
+
// pickers. The file is shared by the account supervisor and its room children,
|
|
7
|
+
// so one dashboard toggle applies to every room served by this bridge.
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
export const PICKER_AGENT_COMMANDS = ['claude', 'codex', 'thinkpool']
|
|
13
|
+
|
|
14
|
+
const root = () => process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair')
|
|
15
|
+
const defaultFile = () => path.join(root(), 'agent-visibility.json')
|
|
16
|
+
const normalize = (command) => String(command || '').toLowerCase() === 'hermes'
|
|
17
|
+
? 'thinkpool'
|
|
18
|
+
: String(command || '').toLowerCase()
|
|
19
|
+
|
|
20
|
+
export function loadHiddenAgentCommands({ file = defaultFile(), fsImpl = fs } = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const saved = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
|
|
23
|
+
return new Set((Array.isArray(saved?.hidden) ? saved.hidden : [])
|
|
24
|
+
.map(normalize)
|
|
25
|
+
.filter((command) => PICKER_AGENT_COMMANDS.includes(command)))
|
|
26
|
+
} catch {
|
|
27
|
+
return new Set()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function agentVisibilityStates(commands, { hidden = loadHiddenAgentCommands() } = {}) {
|
|
32
|
+
const found = new Set((Array.isArray(commands) ? commands : []).map(normalize))
|
|
33
|
+
const states = PICKER_AGENT_COMMANDS
|
|
34
|
+
.filter((command) => found.has(command))
|
|
35
|
+
.map((cmd) => ({ cmd, enabled: !hidden.has(cmd) }))
|
|
36
|
+
// A corrupt or hand-edited preference must not leave the "+" action with no
|
|
37
|
+
// valid runtime. The first installed product runtime is the safe recovery.
|
|
38
|
+
if (states.length > 0 && !states.some((agent) => agent.enabled)) states[0].enabled = true
|
|
39
|
+
return states
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setAgentPickerVisibility(command, enabled, {
|
|
43
|
+
installed = [],
|
|
44
|
+
file = defaultFile(),
|
|
45
|
+
fsImpl = fs,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const cmd = normalize(command)
|
|
48
|
+
if (!PICKER_AGENT_COMMANDS.includes(cmd)) return { ok: false, error: 'unknown agent runtime' }
|
|
49
|
+
const states = agentVisibilityStates(installed, { hidden: loadHiddenAgentCommands({ file, fsImpl }) })
|
|
50
|
+
if (!states.some((agent) => agent.cmd === cmd)) return { ok: false, error: 'agent runtime is not installed' }
|
|
51
|
+
if (!enabled && states.filter((agent) => agent.enabled).length <= 1) {
|
|
52
|
+
return { ok: false, error: 'keep at least one agent in the terminal picker' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const hidden = new Set(states.filter((agent) => !agent.enabled).map((agent) => agent.cmd))
|
|
56
|
+
if (enabled) hidden.delete(cmd)
|
|
57
|
+
else hidden.add(cmd)
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
61
|
+
fsImpl.writeFileSync(file, JSON.stringify({ hidden: [...hidden] }, null, 2), { mode: 0o600 })
|
|
62
|
+
try { fsImpl.chmodSync(file, 0o600) } catch { /* best-effort hardening */ }
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, error: 'could not save agent visibility' }
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, agents: agentVisibilityStates(installed, { hidden }) }
|
|
67
|
+
}
|
package/bridge.mjs
CHANGED
|
@@ -64,6 +64,7 @@ import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
|
|
|
64
64
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
65
65
|
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
66
66
|
import { commandOnPath } from './agent-detect.mjs'
|
|
67
|
+
import { agentVisibilityStates } from './agent-visibility.mjs'
|
|
67
68
|
import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
|
|
68
69
|
import { hermesUserInputResponse } from './question-response.mjs'
|
|
69
70
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
@@ -1282,6 +1283,15 @@ const replayPump = createLatestReplayPump({
|
|
|
1282
1283
|
// every announce carries them so a late-joining or second device sees them too.
|
|
1283
1284
|
const termNames = loadNames(room)
|
|
1284
1285
|
const manualNameTouched = new Set()
|
|
1286
|
+
const acknowledgeTermOpen = (id, kind, entry = null) => {
|
|
1287
|
+
bcast('term-opened', {
|
|
1288
|
+
id,
|
|
1289
|
+
kind,
|
|
1290
|
+
sideParent: entry?.sideParent || undefined,
|
|
1291
|
+
sideTask: entry?.sideTask || undefined,
|
|
1292
|
+
name: termNames[id] || undefined,
|
|
1293
|
+
})
|
|
1294
|
+
}
|
|
1285
1295
|
const autoNameAttempts = new Set()
|
|
1286
1296
|
const autoNames = new Map()
|
|
1287
1297
|
// The SDK's supported-model LIST (value/displayName/description), captured from
|
|
@@ -1295,6 +1305,7 @@ const announce = () => {
|
|
|
1295
1305
|
// (name-only, NEVER key/baseUrl) so a cross-device viewer can badge a lane opened
|
|
1296
1306
|
// on a custom provider without the owner's account-channel registry.
|
|
1297
1307
|
const provNames = providerNameMap()
|
|
1308
|
+
const pickerState = new Map(agentVisibilityStates(installedAgents.map((agent) => agent.cmd)).map((agent) => [agent.cmd, agent.enabled]))
|
|
1298
1309
|
const rev = ++announceRev
|
|
1299
1310
|
return bcastAwait('bridge', {
|
|
1300
1311
|
v: 2, name, bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT, rev, repo: repoLabel, branch: readBranch(),
|
|
@@ -1353,7 +1364,7 @@ const announce = () => {
|
|
|
1353
1364
|
agents: installedAgents.map(a => {
|
|
1354
1365
|
let canResume = false
|
|
1355
1366
|
if (a.resume) { try { canResume = a.resume.probe() } catch { /* stays false */ } }
|
|
1356
|
-
return { label: a.label, cmd: a.cmd, canResume }
|
|
1367
|
+
return { label: a.label, cmd: a.cmd, canResume, enabled: pickerState.get(a.cmd) !== false }
|
|
1357
1368
|
}),
|
|
1358
1369
|
// cols/rows: the PTY's one true size — web viewers render this grid and
|
|
1359
1370
|
// scale it to their own page instead of voting to reflow it.
|
|
@@ -1724,7 +1735,6 @@ function pumpDesign(term) {
|
|
|
1724
1735
|
}
|
|
1725
1736
|
const active = { ...next, resultOk: false, proof: null, timer: null }
|
|
1726
1737
|
designActive.set(term, active)
|
|
1727
|
-
designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
|
|
1728
1738
|
// Complete room-visible instructions, without host paths or locator packets.
|
|
1729
1739
|
const visible = {
|
|
1730
1740
|
kind: 'you',
|
|
@@ -1745,6 +1755,10 @@ function pumpDesign(term) {
|
|
|
1745
1755
|
} catch { accepted = false }
|
|
1746
1756
|
if (accepted) beginStructuredTurn(lane)
|
|
1747
1757
|
stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
|
|
1758
|
+
// Browser clients use this as the positive dispatch acknowledgment. Keep it
|
|
1759
|
+
// after the accepted turn and its visible prompt so "applying" never navigates
|
|
1760
|
+
// a submitter away from an actionable Design failure.
|
|
1761
|
+
if (accepted) designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
|
|
1748
1762
|
if (!accepted) {
|
|
1749
1763
|
syncStructuredTurn(lane)
|
|
1750
1764
|
const failed = { kind: 'error', message: 'The producing lane could not start the edit.', recoverable: true }
|
|
@@ -4109,6 +4123,13 @@ channel
|
|
|
4109
4123
|
// Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
|
|
4110
4124
|
// opens (older web) are taken by whoever hears them — the solo case.
|
|
4111
4125
|
if (payload.host && payload.host !== name) return
|
|
4126
|
+
// A client retries an open when the first broadcast or this acknowledgement is
|
|
4127
|
+
// lost. PTY creation was already idempotent, but acknowledge the existing lane
|
|
4128
|
+
// explicitly so the watchdog can settle without waiting for a full roster.
|
|
4129
|
+
if (terms.has(payload.id)) {
|
|
4130
|
+
acknowledgeTermOpen(payload.id, 'pty', terms.get(payload.id))
|
|
4131
|
+
return
|
|
4132
|
+
}
|
|
4112
4133
|
// resume is a FLAG, never argv: the channel must not pass arbitrary
|
|
4113
4134
|
// args even though the room is shell-trust by design. The args come
|
|
4114
4135
|
// from our own KNOWN_AGENTS table, probe-gated so `--continue` with
|
|
@@ -4119,6 +4140,15 @@ channel
|
|
|
4119
4140
|
if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
|
|
4120
4141
|
}
|
|
4121
4142
|
if (wantStructured(payload.cmd)) {
|
|
4143
|
+
// The open id is the idempotency key for the ENTIRE operation, including a
|
|
4144
|
+
// side lane's initial task. openStructured() already no-ops for an existing
|
|
4145
|
+
// runtime, but the old handler continued below and published side-started +
|
|
4146
|
+
// dispatched the task again. A retry must only replay the acceptance ack.
|
|
4147
|
+
const existing = sessions.get(payload.id)
|
|
4148
|
+
if (existing) {
|
|
4149
|
+
acknowledgeTermOpen(payload.id, 'structured', existing)
|
|
4150
|
+
return
|
|
4151
|
+
}
|
|
4122
4152
|
// model: the web passes the model to open on — a NEW terminal inherits the
|
|
4123
4153
|
// previous terminal's model (and the first uses the provider default). Falls
|
|
4124
4154
|
// through to the SDK default when absent. Stamped on the entry so the announce
|
|
@@ -4177,13 +4207,17 @@ channel
|
|
|
4177
4207
|
return
|
|
4178
4208
|
}
|
|
4179
4209
|
child.flush?.(); parent.flush?.(); announce()
|
|
4210
|
+
acknowledgeTermOpen(payload.id, 'structured', child)
|
|
4180
4211
|
process.stderr.write(`\n ◆ ${by} opened side lane ${String(payload.id).slice(0, 8)} under ${String(sideParent).slice(0, 8)}.\n`)
|
|
4181
4212
|
return
|
|
4182
4213
|
}
|
|
4214
|
+
const opened = sessions.get(payload.id)
|
|
4215
|
+
if (opened) acknowledgeTermOpen(payload.id, 'structured', opened)
|
|
4183
4216
|
process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
|
|
4184
4217
|
return
|
|
4185
4218
|
}
|
|
4186
4219
|
openTerm({ id: payload.id, cmd: payload.cmd, args })
|
|
4220
|
+
if (terms.has(payload.id)) acknowledgeTermOpen(payload.id, 'pty', terms.get(payload.id))
|
|
4187
4221
|
process.stderr.write(`\n ◆ web opened a "${payload.cmd}"${args.length ? ' (continue)' : ''} terminal (headless).\n`)
|
|
4188
4222
|
})
|
|
4189
4223
|
.on('broadcast', { event: 'term-close' }, ({ payload }) => {
|
|
@@ -5376,6 +5410,7 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
5376
5410
|
process.on('message', (m) => {
|
|
5377
5411
|
if (!m) return
|
|
5378
5412
|
if (m.t === 'update-available') surfaceUpdate(m.version)
|
|
5413
|
+
else if (m.t === 'agent-visibility-changed') void announce()
|
|
5379
5414
|
// Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
|
|
5380
5415
|
// it so our authed writes (code-mockup) never go stale on a long session (L16).
|
|
5381
5416
|
else if (m.t === 'token') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.343",
|
|
4
4
|
"description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"sdk-admission.mjs",
|
|
19
19
|
"launcher.mjs",
|
|
20
20
|
"privacy-report.mjs",
|
|
21
|
+
"agent-visibility.mjs",
|
|
21
22
|
"byok-detect.mjs",
|
|
22
23
|
"context-windows.mjs",
|
|
23
24
|
"claude-session.mjs",
|
package/privacy-report.mjs
CHANGED
|
@@ -42,6 +42,7 @@ export function buildPrivacyReport({
|
|
|
42
42
|
['Saved login', 'auth.json', exists(path.join(configDir, 'auth.json')), 'refresh token and account identity'],
|
|
43
43
|
['Provider registry', 'providers.json', exists(path.join(configDir, 'providers.json')), 'provider endpoint, model, and API key'],
|
|
44
44
|
['Bridge keypair', 'bridge-key.json', exists(path.join(configDir, 'bridge-key.json')), 'private key used to open browser-sealed provider keys'],
|
|
45
|
+
['Agent picker visibility', 'agent-visibility.json', exists(path.join(configDir, 'agent-visibility.json')), 'which installed runtimes are hidden from terminal pickers'],
|
|
45
46
|
['Room directories', 'dirs.json / served.json', exists(path.join(configDir, 'dirs.json')) || exists(path.join(configDir, 'served.json')), 'room-to-project directory mappings'],
|
|
46
47
|
['Bridge logs', 'update.log and service logs', exists(path.join(configDir, 'update.log')), 'update and background-service diagnostics'],
|
|
47
48
|
]
|
|
@@ -105,4 +106,3 @@ export function runPrivacyReport(options) {
|
|
|
105
106
|
process.stdout.write(formatPrivacyReport(report))
|
|
106
107
|
return report
|
|
107
108
|
}
|
|
108
|
-
|