thinkpool-pair 0.7.335 → 0.7.337
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 +38 -3
- package/account.mjs +17 -1
- package/bridge.mjs +43 -4
- package/hermes-model-cache.mjs +54 -0
- package/keep-awake.mjs +148 -0
- package/launcher.mjs +50 -4
- package/package.json +4 -2
- package/service.mjs +6 -1
package/README.md
CHANGED
|
@@ -17,6 +17,11 @@ not require an inbound port, tunnel, or public IP.
|
|
|
17
17
|
- Hermes
|
|
18
18
|
- The provider login or API credentials required by that runtime
|
|
19
19
|
|
|
20
|
+
**The bridge does not include a coding agent.** Claude Code, Codex, or Hermes
|
|
21
|
+
must already be installed and runnable on the bridge machine. BYOK only supplies
|
|
22
|
+
model-provider credentials to an installed runtime; a provider key does not
|
|
23
|
+
install or replace the agent itself.
|
|
24
|
+
|
|
20
25
|
## Start the bridge
|
|
21
26
|
|
|
22
27
|
Run the launcher from the project directory the agents should use:
|
|
@@ -60,6 +65,35 @@ when that trade-off is intentional:
|
|
|
60
65
|
npx thinkpool-pair@latest install-service --auto-update
|
|
61
66
|
```
|
|
62
67
|
|
|
68
|
+
### Optional: keep the bridge computer awake
|
|
69
|
+
|
|
70
|
+
The launcher’s **Settings → Keep computer awake** option prevents system sleep
|
|
71
|
+
while the bridge is running. The same choice is available from the CLI:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Foreground, for this run only
|
|
75
|
+
npx thinkpool-pair@latest --keep-awake
|
|
76
|
+
|
|
77
|
+
# Persist the choice and install the background service
|
|
78
|
+
npx thinkpool-pair@latest install-service --keep-awake
|
|
79
|
+
|
|
80
|
+
# Persistently turn it back off while reinstalling/updating the service
|
|
81
|
+
npx thinkpool-pair@latest install-service --no-keep-awake
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Important:** keep-awake can substantially increase battery use. It prevents
|
|
85
|
+
the computer from sleeping; it does **not** keep the display illuminated.
|
|
86
|
+
Display dimming, screen locking, and display sleep continue normally. Closing a
|
|
87
|
+
laptop lid may still put it to sleep depending on the operating system and its
|
|
88
|
+
power settings. On some Linux systems the sleep inhibitor can also block an
|
|
89
|
+
explicit suspend request while the bridge runs.
|
|
90
|
+
|
|
91
|
+
The implementation is process-bound and releases automatically when the bridge
|
|
92
|
+
stops: macOS uses `caffeinate -i`, Linux uses `systemd-inhibit --what=sleep`,
|
|
93
|
+
and Windows uses a system-only power request without `ES_DISPLAY_REQUIRED`.
|
|
94
|
+
If the platform helper is unavailable, the bridge warns and continues without
|
|
95
|
+
keep-awake rather than failing startup.
|
|
96
|
+
|
|
63
97
|
Service implementation by platform:
|
|
64
98
|
|
|
65
99
|
- macOS: LaunchAgent
|
|
@@ -231,9 +265,10 @@ log before reinstalling; repeated installation can hide the original failure.
|
|
|
231
265
|
|
|
232
266
|
### No runtimes are available
|
|
233
267
|
|
|
234
|
-
Install
|
|
235
|
-
launcher. Runtime availability is detected from the host; the web room
|
|
236
|
-
install a missing CLI
|
|
268
|
+
Install and configure Claude Code, Codex, or Hermes on the host, then restart
|
|
269
|
+
the launcher. Runtime availability is detected from the host; the web room
|
|
270
|
+
cannot install a missing CLI. BYOK configures provider credentials only and
|
|
271
|
+
does not replace the local agent runtime.
|
|
237
272
|
|
|
238
273
|
### A custom model fails immediately
|
|
239
274
|
|
package/account.mjs
CHANGED
|
@@ -526,6 +526,11 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
526
526
|
// never impersonate a completed restart.
|
|
527
527
|
const BRIDGE_ID = randomUUID()
|
|
528
528
|
let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
|
|
529
|
+
// Account-control handlers are registered before the discovery loop is
|
|
530
|
+
// initialized. Route early requests through a harmless placeholder, then
|
|
531
|
+
// replace it once `tick` exists; this avoids a temporal-dead-zone crash during
|
|
532
|
+
// supervisor bootstrap while still letting a live web room request discovery.
|
|
533
|
+
let requestDiscoveryTick = () => {}
|
|
529
534
|
// Web "attach this session" card → bind an unbound/new room to a local dir (and
|
|
530
535
|
// optionally set it as the account default) so it serves WITHOUT a terminal command.
|
|
531
536
|
// Rides the private, owner-gated control topic; the dir must exist on THIS machine.
|
|
@@ -538,7 +543,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
538
543
|
bindDir(code, dir)
|
|
539
544
|
if (payload?.setDefault) { saveDefaultDir(dir); process.stderr.write(`\n ◆ default project dir set → ${dir}\n`) }
|
|
540
545
|
process.stderr.write(`\n ◆ ${code} bound → ${dir} (from the web). Serving…\n`)
|
|
541
|
-
|
|
546
|
+
requestDiscoveryTick()
|
|
547
|
+
})
|
|
548
|
+
// A newly-created web room already knows its owner and has an authenticated,
|
|
549
|
+
// owner-private account-control channel. Nudge discovery immediately instead
|
|
550
|
+
// of waiting for the supervisor's 15-second reconciliation interval. `tick`
|
|
551
|
+
// still re-queries owner-owned active rooms, so the browser cannot make this
|
|
552
|
+
// host serve an arbitrary code.
|
|
553
|
+
acctControl.on('broadcast', { event: 'serve-room' }, ({ payload }) => {
|
|
554
|
+
const code = String(payload?.code || '').toUpperCase().trim()
|
|
555
|
+
if (!/^[A-Z0-9]{4,12}$/.test(code)) return
|
|
556
|
+
requestDiscoveryTick()
|
|
542
557
|
})
|
|
543
558
|
// Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
|
|
544
559
|
// If we're the auto-restarting background service (launchd KeepAlive / systemd
|
|
@@ -906,6 +921,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
906
921
|
tickingSince = now
|
|
907
922
|
try { await tickBody() } finally { if (tickingSince === now) tickingSince = 0 }
|
|
908
923
|
}
|
|
924
|
+
requestDiscoveryTick = () => { void tick().catch(() => {}) }
|
|
909
925
|
// Expose for the watchdog to force-unwedge the guard after a reconnect.
|
|
910
926
|
const clearTickGuard = () => { tickingSince = 0 }
|
|
911
927
|
const tickBody = async () => {
|
package/bridge.mjs
CHANGED
|
@@ -68,6 +68,7 @@ import { hermesUserInputResponse } from './question-response.mjs'
|
|
|
68
68
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
69
69
|
import { queueAbortBarrier, waitForAbortBarrier } from './abort-turn-barrier.mjs'
|
|
70
70
|
import { PAIR_CLI, pairCli } from './command-guidance.mjs'
|
|
71
|
+
import { explicitKeepAwakeChoice, keepAwakeEnabled, saveKeepAwakePreference, startKeepAwake } from './keep-awake.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'
|
|
@@ -130,6 +131,7 @@ import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
|
130
131
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
131
132
|
import { planMeterLine } from './plan-meters.mjs'
|
|
132
133
|
import { priceForModel } from './model-prices.mjs'
|
|
134
|
+
import { loadHermesModelCache, saveHermesModelCache } from './hermes-model-cache.mjs'
|
|
133
135
|
import { makeThrottledTrack, recoveryBackoffMs } from './presence.mjs'
|
|
134
136
|
import { MockupDeliveryQueue, completeMockupManifest, isMockupDeliveryBoundary } from './mockup-delivery.mjs'
|
|
135
137
|
import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
|
|
@@ -140,6 +142,10 @@ import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE, THINKPOOL
|
|
|
140
142
|
const SUPABASE_URL = process.env.TP_SUPABASE_URL || DEFAULT_SUPABASE_URL
|
|
141
143
|
const WEB_BASE = process.env.TP_WEB_BASE || 'https://thinkpool.io'
|
|
142
144
|
const IMAGE_QUEUE_CONFIG = imageQueueConfig()
|
|
145
|
+
// Shared by every Hermes lane in this room child and persisted across room
|
|
146
|
+
// children. Without this, each new room must boot ACP (up to 30s) merely to
|
|
147
|
+
// discover the same public model list before its first real turn.
|
|
148
|
+
let hostHermesModels = loadHermesModelCache()
|
|
143
149
|
|
|
144
150
|
// The anon key is RESOLVED, not baked. It used to be a string literal here, and
|
|
145
151
|
// that literal now sits in 224 published tarballs on 224 users' laptops: disable
|
|
@@ -231,6 +237,17 @@ const pickAgent = (installed) => new Promise((resolve) => {
|
|
|
231
237
|
|
|
232
238
|
const argv = process.argv.slice(2)
|
|
233
239
|
|
|
240
|
+
// CLI service flags also set the durable machine preference. Service updates read
|
|
241
|
+
// the same preference, so a later pinned-runtime update cannot silently turn the
|
|
242
|
+
// requested power behavior off. Foreground `--keep-awake` remains a one-run override.
|
|
243
|
+
if (argv[0] === 'install-service' || argv[0] === 'restart-service') {
|
|
244
|
+
const choice = explicitKeepAwakeChoice(argv)
|
|
245
|
+
if (choice !== null && !saveKeepAwakePreference(choice)) {
|
|
246
|
+
process.stderr.write(' ⚠ could not save the keep-awake setting; service left unchanged.\n')
|
|
247
|
+
process.exit(1)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
234
251
|
if (argv[0] === 'privacy-report') {
|
|
235
252
|
const { runPrivacyReport } = await import('./privacy-report.mjs')
|
|
236
253
|
runPrivacyReport()
|
|
@@ -357,7 +374,10 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
357
374
|
const child = spawn(process.execPath, args, { stdio: 'inherit' })
|
|
358
375
|
child.on('exit', (code) => process.exit(code == null ? 0 : code))
|
|
359
376
|
}),
|
|
360
|
-
serveAccountForeground: async () => {
|
|
377
|
+
serveAccountForeground: async () => {
|
|
378
|
+
startKeepAwake({ enabled: keepAwakeEnabled([]) })
|
|
379
|
+
const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON)
|
|
380
|
+
},
|
|
361
381
|
// Confirmed installs/updates continue in the exact runtime just proven live. This
|
|
362
382
|
// keeps the person inside the npx menu without letting the stale installer claim
|
|
363
383
|
// its in-memory VERSION matches the newly installed managed service.
|
|
@@ -402,6 +422,17 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
402
422
|
: ['anthropic']
|
|
403
423
|
await runProvider(args)
|
|
404
424
|
},
|
|
425
|
+
setKeepAwake: async ({ enabled }) => {
|
|
426
|
+
if (!saveKeepAwakePreference(enabled)) {
|
|
427
|
+
io.print('\n ⚠ could not save the keep-awake setting; nothing was changed.')
|
|
428
|
+
return false
|
|
429
|
+
}
|
|
430
|
+
// A managed Unix service must restart to acquire/release its process-bound
|
|
431
|
+
// inhibitor. Windows' Startup entry has no daemon control; restartService
|
|
432
|
+
// prints the honest close-and-relaunch instruction instead.
|
|
433
|
+
if (svc.isServiceInstalled(null)) svc.restartService(null)
|
|
434
|
+
return true
|
|
435
|
+
},
|
|
405
436
|
setupHermes: async ({ mode }) => {
|
|
406
437
|
const { setupHermesRuntime } = await import('./hermes-setup.mjs')
|
|
407
438
|
const result = setupHermesRuntime({ mode })
|
|
@@ -456,7 +487,10 @@ function checkSdkCompat() {
|
|
|
456
487
|
}
|
|
457
488
|
}
|
|
458
489
|
|
|
459
|
-
if (!argv[0] || argv[0].startsWith('-')) {
|
|
490
|
+
if (!argv[0] || argv[0].startsWith('-')) {
|
|
491
|
+
startKeepAwake({ enabled: keepAwakeEnabled(argv) })
|
|
492
|
+
const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON)
|
|
493
|
+
}
|
|
460
494
|
|
|
461
495
|
const room = (argv[0] || '').toUpperCase().trim()
|
|
462
496
|
if (!room) { console.error(`usage: ${pairCli('<ROOM>', '[--headless]', '[--continue|--fresh]', '[-- <command…>]')} | ${PAIR_CLI} (account mode)`); process.exit(1) }
|
|
@@ -528,6 +562,11 @@ if (_superOwn.includes('--supervise') || _superOwn.includes('--keep-alive')) {
|
|
|
528
562
|
if (shouldHoldMachineLock(process.env)) process.on('exit', () => { try { releaseMachineLock() } catch { /* noop */ } })
|
|
529
563
|
}
|
|
530
564
|
|
|
565
|
+
// Direct single-room mode owns one inhibitor in its actual serving process. Account
|
|
566
|
+
// supervisor children deliberately skip this (the account parent already owns it),
|
|
567
|
+
// and the --supervise wrapper above never reaches this line—only its child does.
|
|
568
|
+
startKeepAwake({ enabled: keepAwakeEnabled(argv) })
|
|
569
|
+
|
|
531
570
|
const headless = argv.includes('--headless')
|
|
532
571
|
// Account-mode children pass --auto=<agent> so a served session opens a live
|
|
533
572
|
// terminal automatically (headless, no TTY/picker) instead of an empty room.
|
|
@@ -2139,7 +2178,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2139
2178
|
? spawnDepth
|
|
2140
2179
|
: (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
|
|
2141
2180
|
const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
|
|
2142
|
-
const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes'
|
|
2181
|
+
const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
|
|
2143
2182
|
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
2144
2183
|
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
2145
2184
|
// registered provider the SDK id is impersonated (see the onEvent guard below), so
|
|
@@ -3311,7 +3350,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3311
3350
|
// A custom Anthropic-compatible endpoint may expose its own catalog through the
|
|
3312
3351
|
// same SDK method. Never publish that as the ROOM's Claude catalog: its models
|
|
3313
3352
|
// already render in the provider group and otherwise leak under ANTHROPIC.
|
|
3314
|
-
if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
|
|
3353
|
+
if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = saveHermesModelCache(evt.models); hostHermesModels = entry.models; announce() }
|
|
3315
3354
|
else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
|
|
3316
3355
|
// Hermes model changes are asynchronous ACP requests. Keep the previous model
|
|
3317
3356
|
// authoritative until Hermes acknowledges session/set_model; then settle the
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Hermes exposes its model catalog only after `session/new`, which may take up
|
|
2
|
+
// to 30 seconds while the ACP process boots. The catalog is public metadata and
|
|
3
|
+
// identical for every room served by this Hermes profile, so keep the last
|
|
4
|
+
// validated copy at the host level. A live Hermes session always replaces it.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
const MAX_MODELS = 1000
|
|
11
|
+
const MAX_BYTES = 512 * 1024
|
|
12
|
+
const MAX_FIELD = 512
|
|
13
|
+
|
|
14
|
+
const cachePath = () => process.env.TP_HERMES_MODEL_CACHE
|
|
15
|
+
|| path.join(process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair'), 'hermes-models.json')
|
|
16
|
+
|
|
17
|
+
export function normalizeHermesModelCache(models) {
|
|
18
|
+
if (!Array.isArray(models)) return []
|
|
19
|
+
const seen = new Set()
|
|
20
|
+
const normalized = []
|
|
21
|
+
for (const raw of models.slice(0, MAX_MODELS)) {
|
|
22
|
+
const value = String(raw?.value || '').trim().slice(0, MAX_FIELD)
|
|
23
|
+
if (!value || seen.has(value)) continue
|
|
24
|
+
seen.add(value)
|
|
25
|
+
normalized.push({
|
|
26
|
+
value,
|
|
27
|
+
displayName: String(raw?.displayName || value).trim().slice(0, MAX_FIELD) || value,
|
|
28
|
+
description: String(raw?.description || '').trim().slice(0, MAX_FIELD),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
return normalized
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadHermesModelCache() {
|
|
35
|
+
try {
|
|
36
|
+
const file = cachePath()
|
|
37
|
+
if ((Number(fs.statSync(file).size) || 0) > MAX_BYTES) return []
|
|
38
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
39
|
+
return normalizeHermesModelCache(parsed?.models)
|
|
40
|
+
} catch { return [] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function saveHermesModelCache(models) {
|
|
44
|
+
const normalized = normalizeHermesModelCache(models)
|
|
45
|
+
if (!normalized.length) return []
|
|
46
|
+
try {
|
|
47
|
+
const file = cachePath()
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
49
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
50
|
+
fs.writeFileSync(temp, JSON.stringify({ savedAt: Date.now(), models: normalized }), { mode: 0o600 })
|
|
51
|
+
fs.renameSync(temp, file)
|
|
52
|
+
} catch { /* cache is opportunistic; live discovery remains the fallback */ }
|
|
53
|
+
return normalized
|
|
54
|
+
}
|
package/keep-awake.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* keep-awake.mjs — one explicit, machine-wide bridge preference with a
|
|
2
|
+
platform-native implementation. The assertion is SYSTEM-only: it never asks
|
|
3
|
+
any platform to keep the display lit, so normal dimming, display sleep, and
|
|
4
|
+
screen locking remain available.
|
|
5
|
+
|
|
6
|
+
The helper is deliberately tied to the bridge PID. A crash, Ctrl-C, update,
|
|
7
|
+
or service-manager replacement therefore releases the assertion without a
|
|
8
|
+
sticky machine-level power-policy change. */
|
|
9
|
+
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
const SETTINGS_FILE = 'settings.json'
|
|
17
|
+
|
|
18
|
+
function settingsPath(home = os.homedir()) {
|
|
19
|
+
return path.join(home, '.thinkpool-pair', SETTINGS_FILE)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadKeepAwakePreference({ home = os.homedir(), fsImpl = fs } = {}) {
|
|
23
|
+
try { return JSON.parse(fsImpl.readFileSync(settingsPath(home), 'utf8')).keepAwake === true }
|
|
24
|
+
catch { return false }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveKeepAwakePreference(enabled, { home = os.homedir(), fsImpl = fs } = {}) {
|
|
28
|
+
const file = settingsPath(home)
|
|
29
|
+
const dir = path.dirname(file)
|
|
30
|
+
let current = {}
|
|
31
|
+
try { current = JSON.parse(fsImpl.readFileSync(file, 'utf8')) || {} } catch { /* first setting */ }
|
|
32
|
+
const next = { ...current, keepAwake: enabled === true }
|
|
33
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`
|
|
34
|
+
try {
|
|
35
|
+
fsImpl.mkdirSync(dir, { recursive: true })
|
|
36
|
+
fsImpl.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 })
|
|
37
|
+
fsImpl.renameSync(tmp, file)
|
|
38
|
+
return true
|
|
39
|
+
} catch {
|
|
40
|
+
try { fsImpl.rmSync(tmp, { force: true }) } catch { /* noop */ }
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function explicitKeepAwakeChoice(argv = []) {
|
|
46
|
+
const dash = argv.indexOf('--')
|
|
47
|
+
const own = dash >= 0 ? argv.slice(0, dash) : argv
|
|
48
|
+
if (own.includes('--no-keep-awake')) return false
|
|
49
|
+
if (own.includes('--keep-awake')) return true
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function keepAwakeEnabled(argv = [], { env = process.env, loadPreference = loadKeepAwakePreference } = {}) {
|
|
54
|
+
const explicit = explicitKeepAwakeChoice(argv)
|
|
55
|
+
if (explicit !== null) return explicit
|
|
56
|
+
if (env.THINKPOOL_PAIR_KEEP_AWAKE === '1') return true
|
|
57
|
+
if (env.THINKPOOL_PAIR_KEEP_AWAKE === '0') return false
|
|
58
|
+
return loadPreference() === true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const WINDOWS_HELPER = (parentPid) => [
|
|
62
|
+
'$ErrorActionPreference = "Stop"',
|
|
63
|
+
'Add-Type -TypeDefinition \'using System; using System.Runtime.InteropServices; public static class ThinkpoolPower { [DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint flags); }\'',
|
|
64
|
+
// ES_CONTINUOUS | ES_SYSTEM_REQUIRED. ES_DISPLAY_REQUIRED (0x2) is intentionally absent.
|
|
65
|
+
'[ThinkpoolPower]::SetThreadExecutionState([uint32]0x80000001) | Out-Null',
|
|
66
|
+
`try { while (Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 5 } }`,
|
|
67
|
+
'finally { [ThinkpoolPower]::SetThreadExecutionState([uint32]0x80000000) | Out-Null }',
|
|
68
|
+
].join('; ')
|
|
69
|
+
|
|
70
|
+
// Pure command construction keeps the display-sleep contract testable on any host.
|
|
71
|
+
export function keepAwakeCommand(platform, parentPid, { node = process.execPath, modulePath = fileURLToPath(import.meta.url) } = {}) {
|
|
72
|
+
if (!Number.isInteger(parentPid) || parentPid <= 0) throw new Error('keep-awake requires a live bridge pid')
|
|
73
|
+
if (platform === 'darwin') {
|
|
74
|
+
// -i is the idle SYSTEM-sleep assertion. Do not add -d (display).
|
|
75
|
+
// -w makes caffeinate release automatically when the bridge PID exits.
|
|
76
|
+
return { command: '/usr/bin/caffeinate', args: ['-i', '-w', String(parentPid)], adapter: 'caffeinate' }
|
|
77
|
+
}
|
|
78
|
+
if (platform === 'linux') {
|
|
79
|
+
// Inhibit the sleep operation, not desktop "idle" handling: an idle inhibitor
|
|
80
|
+
// can also suppress screen blanking in some desktop environments. The tiny Node
|
|
81
|
+
// waiter exits with the parent, releasing systemd-logind's inhibitor lock.
|
|
82
|
+
return {
|
|
83
|
+
command: 'systemd-inhibit',
|
|
84
|
+
args: ['--what=sleep', '--mode=block', '--who=Thinkpool', '--why=Keep the Thinkpool bridge reachable', '--', node, modulePath, '--wait-for', String(parentPid)],
|
|
85
|
+
adapter: 'systemd-inhibit',
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (platform === 'win32') {
|
|
89
|
+
return {
|
|
90
|
+
command: 'powershell.exe',
|
|
91
|
+
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', WINDOWS_HELPER(parentPid)],
|
|
92
|
+
adapter: 'Windows power request',
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function startKeepAwake({
|
|
99
|
+
enabled,
|
|
100
|
+
platform = process.platform,
|
|
101
|
+
parentPid = process.pid,
|
|
102
|
+
spawnImpl = spawn,
|
|
103
|
+
stderr = process.stderr,
|
|
104
|
+
accountChild = process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1',
|
|
105
|
+
} = {}) {
|
|
106
|
+
if (!enabled || accountChild) return null
|
|
107
|
+
let spec
|
|
108
|
+
try { spec = keepAwakeCommand(platform, parentPid) }
|
|
109
|
+
catch (error) { stderr.write(` ⚠ keep-awake unavailable: ${error?.message || error}\n`); return null }
|
|
110
|
+
if (!spec) {
|
|
111
|
+
stderr.write(` ⚠ keep-awake is not supported on ${platform}; bridge startup will continue normally.\n`)
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let child
|
|
116
|
+
const startedAt = Date.now()
|
|
117
|
+
try {
|
|
118
|
+
child = spawnImpl(spec.command, spec.args, { stdio: 'ignore', windowsHide: true })
|
|
119
|
+
child.once?.('spawn', () => {
|
|
120
|
+
stderr.write(` ◆ keep-awake ON via ${spec.adapter}: system sleep is inhibited while this bridge runs.\n Your screen can still dim, lock, and turn off normally.\n`)
|
|
121
|
+
})
|
|
122
|
+
child.once?.('error', (error) => {
|
|
123
|
+
stderr.write(` ⚠ keep-awake could not start (${spec.adapter}: ${error?.message || error}); bridge startup will continue normally.\n`)
|
|
124
|
+
})
|
|
125
|
+
child.once?.('exit', (code) => {
|
|
126
|
+
if (code && Date.now() - startedAt < 30_000) stderr.write(` ⚠ keep-awake helper exited early (${spec.adapter}, code ${code}); the computer may sleep normally.\n`)
|
|
127
|
+
})
|
|
128
|
+
child.unref?.()
|
|
129
|
+
return child
|
|
130
|
+
} catch (error) {
|
|
131
|
+
stderr.write(` ⚠ keep-awake could not start (${spec.adapter}: ${error?.message || error}); bridge startup will continue normally.\n`)
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function waitForProcess(pid) {
|
|
137
|
+
for (;;) {
|
|
138
|
+
try { process.kill(pid, 0) } catch { return }
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Linux systemd-inhibit owns this helper as its COMMAND. It holds no power
|
|
144
|
+
// assertion itself; its lifetime simply matches the bridge process.
|
|
145
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--wait-for') {
|
|
146
|
+
const pid = Number(process.argv[3])
|
|
147
|
+
if (Number.isInteger(pid) && pid > 0) await waitForProcess(pid)
|
|
148
|
+
}
|
package/launcher.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { execSync } from 'node:child_process'
|
|
|
17
17
|
import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
|
|
18
18
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
19
19
|
import { darwinServiceRunning, serviceRuntimeVersion } from './service.mjs'
|
|
20
|
+
import { loadKeepAwakePreference } from './keep-awake.mjs'
|
|
20
21
|
|
|
21
22
|
const HOME = os.homedir()
|
|
22
23
|
const CFG_DIR = path.join(HOME, '.thinkpool-pair')
|
|
@@ -86,6 +87,7 @@ export function detectState() {
|
|
|
86
87
|
hermesInstalled,
|
|
87
88
|
hermesReady,
|
|
88
89
|
accountSvc,
|
|
90
|
+
keepAwake: loadKeepAwakePreference(),
|
|
89
91
|
serviceVersion: accountSvc ? serviceRuntimeVersion(null) : null,
|
|
90
92
|
platform: process.platform,
|
|
91
93
|
cwd: process.cwd(),
|
|
@@ -110,6 +112,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
110
112
|
loggedIn: !!next?.loggedIn,
|
|
111
113
|
reconnectRequired: !!next?.reconnectRequired,
|
|
112
114
|
accountSvc: !!next?.accountSvc,
|
|
115
|
+
keepAwake: next?.keepAwake === true,
|
|
113
116
|
serviceVersion: typeof next?.serviceVersion === 'string' && next.serviceVersion ? next.serviceVersion : null,
|
|
114
117
|
platform: next?.platform || process.platform,
|
|
115
118
|
provider: next?.provider || 'Anthropic (default)',
|
|
@@ -134,7 +137,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
134
137
|
io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
|
|
135
138
|
io.print(` ${C.dim('│ launcher ')} ${state.version ? `v${state.version}` : 'version unavailable'}`)
|
|
136
139
|
io.print(` ${C.dim('│ account ')} ${state.reconnectRequired ? C.yellow('reconnect required') : state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
|
|
137
|
-
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready')}`)
|
|
140
|
+
io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none ready: install Claude Code, Codex, or Hermes')}`)
|
|
138
141
|
io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
|
|
139
142
|
const bridgeStatus = !state.accountSvc
|
|
140
143
|
? 'not installed'
|
|
@@ -142,6 +145,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
142
145
|
? C.green('startup entry installed')
|
|
143
146
|
: C.green(`running${state.serviceVersion ? ` v${state.serviceVersion}` : ' (version unavailable)'}`)
|
|
144
147
|
io.print(` ${C.dim('│ bridge ')} ${bridgeStatus}`)
|
|
148
|
+
io.print(` ${C.dim('│ keep awake')} ${state.keepAwake ? C.green('on') + C.dim(', display may still dim') : 'off'}`)
|
|
145
149
|
io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
|
|
146
150
|
}
|
|
147
151
|
|
|
@@ -176,8 +180,25 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
176
180
|
return true
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
// A provider key is credentials, not an executable agent. Stop new installs
|
|
184
|
+
// before they create an apparently healthy but unusable background bridge.
|
|
185
|
+
const ensureAgentReady = async () => {
|
|
186
|
+
if (state.agents.length) return true
|
|
187
|
+
io.print('\n ' + C.yellow('No coding agent is ready on this computer'))
|
|
188
|
+
io.print(' Thinkpool connects an agent already installed on this machine. It does not')
|
|
189
|
+
io.print(' include one. Install and configure at least one of:')
|
|
190
|
+
io.print(`\n ${C.cyan('Claude Code')} command: claude`)
|
|
191
|
+
io.print(` ${C.cyan('Codex')} command: codex`)
|
|
192
|
+
io.print(` ${C.cyan('Hermes')} command: hermes, then set up its Thinkpool profile here`)
|
|
193
|
+
io.print('\n ' + C.bold('BYOK does not replace the agent. It only gives an installed agent'))
|
|
194
|
+
io.print(' credentials for a model provider.')
|
|
195
|
+
io.print('\n Install one, then reopen this launcher.')
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
|
|
179
199
|
const providerMenu = async () => {
|
|
180
200
|
const cur = state.provider + (state.providerModel ? `, model ${state.providerModel}` : '')
|
|
201
|
+
io.print('\n ' + C.dim('Provider settings supply model credentials. Claude Code, Codex, or Hermes must still be installed locally.'))
|
|
181
202
|
const p = await askChoice('\n provider', [
|
|
182
203
|
{ label: 'Anthropic (default)', hint: 'regular Claude login' },
|
|
183
204
|
{ label: 'Paste a key, pick a model', hint: 'auto-detects the provider · OpenRouter = any model' },
|
|
@@ -236,6 +257,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
236
257
|
const options = [
|
|
237
258
|
{ key: 'provider', label: 'Provider', hint: `current: ${state.provider}` },
|
|
238
259
|
{ key: 'account', label: 'Account', hint: state.reconnectRequired ? 'reconnect required' : state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
|
|
260
|
+
{ key: 'awake', label: 'Keep computer awake', hint: `${state.keepAwake ? 'ON' : 'off'} · the screen can still dim, lock, and turn off` },
|
|
239
261
|
]
|
|
240
262
|
if (state.hermesInstalled) options.push({ key: 'hermes', label: state.hermesReady ? 'Hermes profile' : 'Set up Hermes', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
|
|
241
263
|
options.push({ key: 'back', label: 'Back to main menu', hint: '' })
|
|
@@ -243,6 +265,30 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
243
265
|
if (picked.key === 'back') return
|
|
244
266
|
if (picked.key === 'provider') await providerMenu()
|
|
245
267
|
else if (picked.key === 'account') { await actions.login(); resync(); return }
|
|
268
|
+
else if (picked.key === 'awake') {
|
|
269
|
+
if (state.keepAwake) {
|
|
270
|
+
const changed = await actions.setKeepAwake({ enabled: false })
|
|
271
|
+
if (changed === false) continue
|
|
272
|
+
state = { ...state, keepAwake: false }
|
|
273
|
+
resync()
|
|
274
|
+
io.print('\n ' + C.green('✓ keep-awake is off. The computer may sleep normally.'))
|
|
275
|
+
} else {
|
|
276
|
+
io.print('\n ' + C.yellow('Important: keep-awake changes this computer’s power behavior'))
|
|
277
|
+
io.print(' While the bridge runs, Thinkpool will prevent the computer itself from')
|
|
278
|
+
io.print(' automatically sleeping. This can use substantially more battery and may')
|
|
279
|
+
io.print(' also block suspend requests on some Linux systems.')
|
|
280
|
+
io.print('\n ' + C.green('It does NOT keep the screen on. Display dimming, screen locking, and'))
|
|
281
|
+
io.print(' display sleep continue to work normally. Closing a laptop lid may still')
|
|
282
|
+
io.print(' sleep it, depending on the operating system and power settings.')
|
|
283
|
+
if (await askYesNo('\n Enable keep-awake while the Thinkpool bridge runs?', false)) {
|
|
284
|
+
const changed = await actions.setKeepAwake({ enabled: true })
|
|
285
|
+
if (changed === false) continue
|
|
286
|
+
state = { ...state, keepAwake: true }
|
|
287
|
+
resync()
|
|
288
|
+
io.print('\n ' + C.green('✓ keep-awake is on. Your screen is still free to dim and turn off.'))
|
|
289
|
+
} else io.print('\n ' + C.yellow('unchanged: keep-awake remains off.'))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
246
292
|
else if (picked.key === 'hermes') {
|
|
247
293
|
const setup = await askChoice('\n Hermes profile', [
|
|
248
294
|
{ label: 'Clone active profile', hint: 'copies provider/config explicitly; fresh ThinkPool session history' },
|
|
@@ -277,13 +323,13 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
|
|
|
277
323
|
{ key: 'serve', label: 'Serve all my sessions', hint: 'runs here, Ctrl-C stops it' },
|
|
278
324
|
{ key: 'service', label: 'Always-on background service', hint: 'recommended — survives reboot, restarts on crash' },
|
|
279
325
|
]
|
|
280
|
-
items.push({ key: 'settings', label: 'Settings', hint: `provider · account${state.hermesInstalled ? ' · Hermes profile' : ''}` })
|
|
326
|
+
items.push({ key: 'settings', label: 'Settings', hint: `provider · account · keep-awake ${state.keepAwake ? 'ON' : 'off'}${state.hermesInstalled ? ' · Hermes profile' : ''}` })
|
|
281
327
|
items.push({ key: 'quit', label: 'Quit', hint: '' })
|
|
282
328
|
const pick = items[await askChoice('choose', items)]
|
|
283
329
|
if (pick.key === 'quit') return
|
|
284
|
-
if (pick.key === 'serve') { if (await ensureLoggedIn()) await actions.serveAccountForeground() }
|
|
330
|
+
if (pick.key === 'serve') { if (await ensureAgentReady() && await ensureLoggedIn()) await actions.serveAccountForeground() }
|
|
285
331
|
else if (pick.key === 'service') {
|
|
286
|
-
if (await ensureLoggedIn()) {
|
|
332
|
+
if (await ensureAgentReady() && await ensureLoggedIn()) {
|
|
287
333
|
const installed = await actions.installService({ room: null })
|
|
288
334
|
if (installed === true) {
|
|
289
335
|
if (!actions.relaunchLauncher || await actions.relaunchLauncher()) return
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.337",
|
|
4
|
+
"description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"thinkpool-pair": "bridge.mjs"
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"command-guidance.mjs",
|
|
13
13
|
"abort-turn-barrier.mjs",
|
|
14
14
|
"host-memory.mjs",
|
|
15
|
+
"keep-awake.mjs",
|
|
15
16
|
"sdk-smoke.mjs",
|
|
16
17
|
"sdk-admission.mjs",
|
|
17
18
|
"sdk-admission.mjs",
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"codex-commands.mjs",
|
|
37
38
|
"acp-client.mjs",
|
|
38
39
|
"hermes-session.mjs",
|
|
40
|
+
"hermes-model-cache.mjs",
|
|
39
41
|
"hermes-policy.mjs",
|
|
40
42
|
"hermes-acp-bootstrap.py",
|
|
41
43
|
"hermes-event-mapper.mjs",
|
package/service.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { execSync } from 'node:child_process'
|
|
|
25
25
|
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
26
26
|
import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
|
|
27
27
|
import { pairCli } from './command-guidance.mjs'
|
|
28
|
+
import { loadKeepAwakePreference } from './keep-awake.mjs'
|
|
28
29
|
|
|
29
30
|
// Service identity. Account mode has no room → a single stable id so there's
|
|
30
31
|
// exactly one account service per machine (a second install replaces it).
|
|
@@ -591,7 +592,11 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
|
|
|
591
592
|
: `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.`
|
|
592
593
|
const removeArg = room ? ` ${room}` : ''
|
|
593
594
|
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
594
|
-
|
|
595
|
+
const awakeNote = loadKeepAwakePreference()
|
|
596
|
+
? ' ◆ Keep-awake on: system sleep is inhibited while the bridge runs (higher battery use); the display may still dim, lock, and turn off.\n'
|
|
597
|
+
: ''
|
|
598
|
+
const runtimeNote = ' ◆ Prerequisite: Claude Code, Codex, or Hermes must be installed on this machine. BYOK supplies model credentials; it does not install an agent.\n'
|
|
599
|
+
process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n${runtimeNote}${awakeNote} ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: ${pairCli('uninstall-service')}${removeArg}\n\n`)
|
|
595
600
|
return true
|
|
596
601
|
}
|
|
597
602
|
|