thinkpool-pair 0.7.364 → 0.7.366
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/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/hermes-setup.mjs
CHANGED
|
@@ -1,167 +1 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { execFileSync } from 'node:child_process'
|
|
5
|
-
import YAML from 'yaml'
|
|
6
|
-
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
7
|
-
|
|
8
|
-
export const HERMES_PROFILE = 'thinkpool'
|
|
9
|
-
export const HERMES_PROVIDER = 'nous'
|
|
10
|
-
export const HERMES_DEFAULT_MODEL = 'openai/gpt-5.6-luna'
|
|
11
|
-
export const HERMES_GUARD_FILENAME = 'hermes-delegation-guard.mjs'
|
|
12
|
-
|
|
13
|
-
const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
|
|
14
|
-
const commandText = (guardPath) => `node ${JSON.stringify(guardPath)}`
|
|
15
|
-
const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
-
const isThinkPoolGuard = (entry) => isObject(entry)
|
|
17
|
-
&& entry.matcher === '^delegate_task$'
|
|
18
|
-
&& new RegExp(`${HERMES_GUARD_FILENAME.replaceAll('.', '\\.')}$`).test(String(entry.command || '').replace(/["']/g, ''))
|
|
19
|
-
|
|
20
|
-
export function hermesGuardPath({ homeDir = os.homedir(), env = process.env, platform = process.platform } = {}) {
|
|
21
|
-
const dataRoot = platform === 'win32'
|
|
22
|
-
? (env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'))
|
|
23
|
-
: (env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'))
|
|
24
|
-
return path.join(dataRoot, 'thinkpool-pair', HERMES_GUARD_FILENAME)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function atomicWrite(filePath, content, { fsImpl = fs, mode = 0o600 } = {}) {
|
|
28
|
-
fsImpl.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
29
|
-
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
|
30
|
-
try {
|
|
31
|
-
fsImpl.writeFileSync(tempPath, content, { mode })
|
|
32
|
-
fsImpl.chmodSync?.(tempPath, mode)
|
|
33
|
-
fsImpl.renameSync(tempPath, filePath)
|
|
34
|
-
} finally {
|
|
35
|
-
try { fsImpl.rmSync(tempPath, { force: true }) } catch { /* already renamed */ }
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function snapshotFile(filePath, fsImpl) {
|
|
40
|
-
if (!fsImpl.existsSync(filePath)) return null
|
|
41
|
-
return {
|
|
42
|
-
content: fsImpl.readFileSync(filePath),
|
|
43
|
-
mode: fsImpl.statSync(filePath).mode & 0o777,
|
|
44
|
-
atimeMs: fsImpl.statSync(filePath).atimeMs,
|
|
45
|
-
mtimeMs: fsImpl.statSync(filePath).mtimeMs,
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function restoreFile(filePath, snapshot, fsImpl) {
|
|
50
|
-
if (snapshot) {
|
|
51
|
-
atomicWrite(filePath, snapshot.content, { fsImpl, mode: snapshot.mode })
|
|
52
|
-
fsImpl.utimesSync?.(filePath, snapshot.atimeMs / 1000, snapshot.mtimeMs / 1000)
|
|
53
|
-
}
|
|
54
|
-
else fsImpl.rmSync(filePath, { force: true })
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function profileAliasReady(output, profile) {
|
|
58
|
-
const escaped = profile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
59
|
-
return new RegExp(`Alias:\\s+${escaped}\\s+.*hermes\\s+-p\\s+${escaped}`, 'i').test(String(output || ''))
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function setupHermesRuntime({
|
|
63
|
-
mode,
|
|
64
|
-
profile = HERMES_PROFILE,
|
|
65
|
-
command = 'hermes',
|
|
66
|
-
env = process.env,
|
|
67
|
-
homeDir = os.homedir(),
|
|
68
|
-
platform = process.platform,
|
|
69
|
-
execFile = execFileSync,
|
|
70
|
-
fsImpl = fs,
|
|
71
|
-
guardSource = fs.readFileSync(new URL('./hermes-delegation-guard.mjs', import.meta.url), 'utf8'),
|
|
72
|
-
} = {}) {
|
|
73
|
-
if (profile !== HERMES_PROFILE) throw new Error(`ThinkPool only manages the isolated Hermes profile ${HERMES_PROFILE}`)
|
|
74
|
-
if (mode != null && mode !== 'clone' && mode !== 'clean') throw new Error('Hermes setup mode must be clone or clean')
|
|
75
|
-
|
|
76
|
-
const run = (args, options = {}) => execFile(command, args, {
|
|
77
|
-
encoding: 'utf8', env: options.env || env, timeout: options.timeout || 20_000,
|
|
78
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
79
|
-
})
|
|
80
|
-
const profileArgs = ['-p', profile]
|
|
81
|
-
let show = ''
|
|
82
|
-
let created = false
|
|
83
|
-
try { show = run(['profile', 'show', profile]) } catch { /* absent */ }
|
|
84
|
-
if (!/Profile:\s*thinkpool/i.test(show)) {
|
|
85
|
-
if (!mode) throw new Error('No isolated Hermes thinkpool profile exists; choose --clone or --clean')
|
|
86
|
-
const createArgs = ['profile', 'create', profile, '--description', 'Isolated ThinkPool Code runtime.']
|
|
87
|
-
if (mode === 'clone') createArgs.push('--clone')
|
|
88
|
-
else createArgs.push('--no-skills')
|
|
89
|
-
run(createArgs, { timeout: 60_000 })
|
|
90
|
-
created = true
|
|
91
|
-
show = run(['profile', 'show', profile])
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (!profileAliasReady(show, profile)) {
|
|
95
|
-
run(['profile', 'alias', profile, '--name', profile])
|
|
96
|
-
show = run(['profile', 'show', profile])
|
|
97
|
-
if (!profileAliasReady(show, profile)) throw new Error('Hermes did not create the required thinkpool profile alias')
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const rawConfigPath = clean(run([...profileArgs, 'config', 'path']))
|
|
101
|
-
if (!rawConfigPath) throw new Error('Hermes did not report the thinkpool profile config path')
|
|
102
|
-
const configPath = rawConfigPath.startsWith('~') ? path.join(homeDir, rawConfigPath.slice(1).replace(/^[/\\]/, '')) : path.resolve(rawConfigPath)
|
|
103
|
-
const profilePathMatch = String(show).match(/^Path:\s*(.+)$/mi)
|
|
104
|
-
if (!profilePathMatch) throw new Error('Hermes did not report the isolated thinkpool profile path')
|
|
105
|
-
const profilePath = profilePathMatch[1].trim().startsWith('~')
|
|
106
|
-
? path.join(homeDir, profilePathMatch[1].trim().slice(1).replace(/^[/\\]/, ''))
|
|
107
|
-
: path.resolve(profilePathMatch[1].trim())
|
|
108
|
-
const relativeConfig = path.relative(profilePath, configPath)
|
|
109
|
-
if (relativeConfig.startsWith('..') || path.isAbsolute(relativeConfig)) throw new Error('Hermes reported a config path outside the isolated thinkpool profile')
|
|
110
|
-
let config = {}
|
|
111
|
-
if (fsImpl.existsSync(configPath)) {
|
|
112
|
-
const parsed = YAML.parse(fsImpl.readFileSync(configPath, 'utf8'))
|
|
113
|
-
if (parsed != null && !isObject(parsed)) throw new Error('Hermes thinkpool config is not a YAML mapping; refusing to overwrite it')
|
|
114
|
-
config = parsed || {}
|
|
115
|
-
}
|
|
116
|
-
if (config.hooks != null && !isObject(config.hooks)) throw new Error('Hermes thinkpool hooks config is not a mapping; refusing to overwrite it')
|
|
117
|
-
if (config.model != null && !isObject(config.model)) throw new Error('Hermes thinkpool model config is not a mapping; refusing to overwrite it')
|
|
118
|
-
const model = config.model ||= {}
|
|
119
|
-
model.provider = HERMES_PROVIDER
|
|
120
|
-
model.default = HERMES_DEFAULT_MODEL
|
|
121
|
-
// Provider-specific transport overrides from a cloned profile must never
|
|
122
|
-
// survive into ThinkPool's Nous-only runtime.
|
|
123
|
-
delete model.base_url
|
|
124
|
-
delete model.api_mode
|
|
125
|
-
const hooks = config.hooks ||= {}
|
|
126
|
-
if (hooks.pre_tool_call != null && !Array.isArray(hooks.pre_tool_call)) throw new Error('Hermes pre_tool_call hooks are not a list; refusing to overwrite them')
|
|
127
|
-
const prior = hooks.pre_tool_call || []
|
|
128
|
-
hooks.pre_tool_call = [
|
|
129
|
-
...prior.filter((entry) => !isThinkPoolGuard(entry)),
|
|
130
|
-
{ matcher: '^delegate_task$', command: commandText(hermesGuardPath({ homeDir, env, platform })), timeout: 5 },
|
|
131
|
-
]
|
|
132
|
-
|
|
133
|
-
const guardPath = hermesGuardPath({ homeDir, env, platform })
|
|
134
|
-
const allowlistPath = path.join(profilePath, 'shell-hooks-allowlist.json')
|
|
135
|
-
const guardSnapshot = snapshotFile(guardPath, fsImpl)
|
|
136
|
-
const configSnapshot = snapshotFile(configPath, fsImpl)
|
|
137
|
-
const allowlistSnapshot = snapshotFile(allowlistPath, fsImpl)
|
|
138
|
-
try {
|
|
139
|
-
atomicWrite(guardPath, guardSource, { fsImpl, mode: 0o700 })
|
|
140
|
-
atomicWrite(configPath, YAML.stringify(config, { lineWidth: 0 }), { fsImpl, mode: 0o600 })
|
|
141
|
-
|
|
142
|
-
const consentEnv = { ...env, HERMES_ACCEPT_HOOKS: '1' }
|
|
143
|
-
// Hermes does not refresh an existing approval when the same command's
|
|
144
|
-
// script mtime changes. Revoke only ThinkPool's exact guard command, then
|
|
145
|
-
// let the synthetic test consent to that exact command again.
|
|
146
|
-
run([...profileArgs, 'hooks', 'revoke', commandText(guardPath)], { env: consentEnv })
|
|
147
|
-
const testOutput = run([...profileArgs, 'hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { env: consentEnv })
|
|
148
|
-
if (!/(?:action|decision)["']?\s*[:=]\s*["']block/i.test(testOutput)
|
|
149
|
-
|| !/ThinkPool blocks hidden Hermes delegation/i.test(testOutput)) {
|
|
150
|
-
throw new Error('Hermes ran the ThinkPool delegation guard but did not report an explicit block')
|
|
151
|
-
}
|
|
152
|
-
run([...profileArgs, 'hooks', 'doctor'], { env: consentEnv })
|
|
153
|
-
run([...profileArgs, 'acp', '--check'], { env: consentEnv })
|
|
154
|
-
const probe = probeHermesRuntime({ command, prefixArgs: profileArgs, execFile, env: consentEnv })
|
|
155
|
-
if (!probe.available) throw new Error(`Hermes setup did not pass the final runtime probe: ${probe.reason || 'unavailable'}`)
|
|
156
|
-
return { ...probe, profile, created, mode: created ? mode : 'existing', guardPath, configPath }
|
|
157
|
-
} catch (error) {
|
|
158
|
-
try {
|
|
159
|
-
restoreFile(configPath, configSnapshot, fsImpl)
|
|
160
|
-
restoreFile(guardPath, guardSnapshot, fsImpl)
|
|
161
|
-
restoreFile(allowlistPath, allowlistSnapshot, fsImpl)
|
|
162
|
-
} catch (rollbackError) {
|
|
163
|
-
error.message = `${error.message}; rollback also failed: ${clean(rollbackError.message)}`
|
|
164
|
-
}
|
|
165
|
-
throw error
|
|
166
|
-
}
|
|
167
|
-
}
|
|
1
|
+
import e from"node:fs";import o from"node:os";import t from"node:path";import{execFileSync as r}from"node:child_process";import i from"yaml";import{probeHermesRuntime as n}from"./hermes-probe.mjs";export const HERMES_PROFILE="thinkpool";export const HERMES_PROVIDER="nous";export const HERMES_DEFAULT_MODEL="openai/gpt-5.6-luna";export const HERMES_GUARD_FILENAME="hermes-delegation-guard.mjs";const s=e=>String(e||"").replace(/[\r\n]+/g," ").trim(),l=e=>`node ${JSON.stringify(e)}`,a=e=>null!=e&&"object"==typeof e&&!Array.isArray(e);export function hermesGuardPath({homeDir:e=o.homedir(),env:r=process.env,platform:i=process.platform}={}){const n="win32"===i?r.LOCALAPPDATA||t.join(e,"AppData","Local"):r.XDG_DATA_HOME||t.join(e,".local","share");return t.join(n,"thinkpool-pair",HERMES_GUARD_FILENAME)}function m(o,r,{fsImpl:i=e,mode:n=384}={}){i.mkdirSync(t.dirname(o),{recursive:!0});const s=`${o}.tmp-${process.pid}-${Date.now()}`;try{i.writeFileSync(s,r,{mode:n}),i.chmodSync?.(s,n),i.renameSync(s,o)}finally{try{i.rmSync(s,{force:!0})}catch{}}}function c(e,o){return o.existsSync(e)?{content:o.readFileSync(e),mode:511&o.statSync(e).mode,atimeMs:o.statSync(e).atimeMs,mtimeMs:o.statSync(e).mtimeMs}:null}function p(e,o,t){o?(m(e,o.content,{fsImpl:t,mode:o.mode}),t.utimesSync?.(e,o.atimeMs/1e3,o.mtimeMs/1e3)):t.rmSync(e,{force:!0})}function h(e,o){const t=o.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`Alias:\\s+${t}\\s+.*hermes\\s+-p\\s+${t}`,"i").test(String(e||""))}export function setupHermesRuntime({mode:d,profile:f="thinkpool",command:u="hermes",env:g=process.env,homeDir:E=o.homedir(),platform:w=process.platform,execFile:k=r,fsImpl:_=e,guardSource:y=e.readFileSync(new URL("./hermes-delegation-guard.mjs",import.meta.url),"utf8")}={}){if("thinkpool"!==f)throw new Error("ThinkPool only manages the isolated Hermes profile thinkpool");if(null!=d&&"clone"!==d&&"clean"!==d)throw new Error("Hermes setup mode must be clone or clean");const S=(e,o={})=>k(u,e,{encoding:"utf8",env:o.env||g,timeout:o.timeout||2e4,stdio:["ignore","pipe","pipe"]}),v=["-p",f];let A="",H=!1;try{A=S(["profile","show",f])}catch{}if(!/Profile:\s*thinkpool/i.test(A)){if(!d)throw new Error("No isolated Hermes thinkpool profile exists; choose --clone or --clean");const e=["profile","create",f,"--description","Isolated ThinkPool Code runtime."];"clone"===d?e.push("--clone"):e.push("--no-skills"),S(e,{timeout:6e4}),H=!0,A=S(["profile","show",f])}if(!h(A,f)&&(S(["profile","alias",f,"--name",f]),A=S(["profile","show",f]),!h(A,f)))throw new Error("Hermes did not create the required thinkpool profile alias");const M=s(S([...v,"config","path"]));if(!M)throw new Error("Hermes did not report the thinkpool profile config path");const R=M.startsWith("~")?t.join(E,M.slice(1).replace(/^[/\\]/,"")):t.resolve(M),D=String(A).match(/^Path:\s*(.+)$/im);if(!D)throw new Error("Hermes did not report the isolated thinkpool profile path");const x=D[1].trim().startsWith("~")?t.join(E,D[1].trim().slice(1).replace(/^[/\\]/,"")):t.resolve(D[1].trim()),P=t.relative(x,R);if(P.startsWith("..")||t.isAbsolute(P))throw new Error("Hermes reported a config path outside the isolated thinkpool profile");let $={};if(_.existsSync(R)){const e=i.parse(_.readFileSync(R,"utf8"));if(null!=e&&!a(e))throw new Error("Hermes thinkpool config is not a YAML mapping; refusing to overwrite it");$=e||{}}if(null!=$.hooks&&!a($.hooks))throw new Error("Hermes thinkpool hooks config is not a mapping; refusing to overwrite it");if(null!=$.model&&!a($.model))throw new Error("Hermes thinkpool model config is not a mapping; refusing to overwrite it");const b=$.model||={};b.provider="nous",b.default=HERMES_DEFAULT_MODEL,delete b.base_url,delete b.api_mode;const L=$.hooks||={};if(null!=L.pre_tool_call&&!Array.isArray(L.pre_tool_call))throw new Error("Hermes pre_tool_call hooks are not a list; refusing to overwrite them");const F=L.pre_tool_call||[];L.pre_tool_call=[...F.filter(e=>!(e=>a(e)&&"^delegate_task$"===e.matcher&&new RegExp(`${HERMES_GUARD_FILENAME.replaceAll(".","\\.")}$`).test(String(e.command||"").replace(/["']/g,"")))(e)),{matcher:"^delegate_task$",command:l(hermesGuardPath({homeDir:E,env:g,platform:w})),timeout:5}];const j=hermesGuardPath({homeDir:E,env:g,platform:w}),I=t.join(x,"shell-hooks-allowlist.json"),O=c(j,_),T=c(R,_),G=c(I,_);try{m(j,y,{fsImpl:_,mode:448}),m(R,i.stringify($,{lineWidth:0}),{fsImpl:_,mode:384});const e={...g,HERMES_ACCEPT_HOOKS:"1"};S([...v,"hooks","revoke",l(j)],{env:e});const o=S([...v,"hooks","test","pre_tool_call","--for-tool","delegate_task"],{env:e});if(!/(?:action|decision)["']?\s*[:=]\s*["']block/i.test(o)||!/ThinkPool blocks hidden Hermes delegation/i.test(o))throw new Error("Hermes ran the ThinkPool delegation guard but did not report an explicit block");S([...v,"hooks","doctor"],{env:e}),S([...v,"acp","--check"],{env:e});const t=n({command:u,prefixArgs:v,execFile:k,env:e});if(!t.available)throw new Error(`Hermes setup did not pass the final runtime probe: ${t.reason||"unavailable"}`);return{...t,profile:f,created:H,mode:H?d:"existing",guardPath:j,configPath:R}}catch(e){try{p(R,T,_),p(j,O,_),p(I,G,_)}catch(o){e.message=`${e.message}; rollback also failed: ${s(o.message)}`}throw e}}
|
package/host-memory.mjs
CHANGED
|
@@ -1,116 +1 @@
|
|
|
1
|
-
import os from
|
|
2
|
-
import fs from 'node:fs'
|
|
3
|
-
import { execFileSync } from 'node:child_process'
|
|
4
|
-
|
|
5
|
-
const MIB = 1024 * 1024
|
|
6
|
-
const DEFAULT_BLOCK_PERCENT = 8
|
|
7
|
-
const DEFAULT_RELEASE_PERCENT = 12
|
|
8
|
-
const DEFAULT_BLOCK_BYTES = 512 * MIB
|
|
9
|
-
const DEFAULT_RELEASE_BYTES = 1024 * MIB
|
|
10
|
-
|
|
11
|
-
function finiteNumber(value, fallback) {
|
|
12
|
-
const n = Number(value)
|
|
13
|
-
return Number.isFinite(n) ? n : fallback
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function parseMacMemoryPressure(output) {
|
|
17
|
-
const match = String(output || '').match(/System-wide memory free percentage:\s*([0-9]+(?:\.[0-9]+)?)%/i)
|
|
18
|
-
return match ? Number(match[1]) : null
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function parseLinuxMemAvailable(output) {
|
|
22
|
-
const text = String(output || '')
|
|
23
|
-
const available = text.match(/^MemAvailable:\s*(\d+)\s*kB\s*$/mi)
|
|
24
|
-
const total = text.match(/^MemTotal:\s*(\d+)\s*kB\s*$/mi)
|
|
25
|
-
if (!available || !total) return null
|
|
26
|
-
return { availableBytes: Number(available[1]) * 1024, totalBytes: Number(total[1]) * 1024, source: 'proc-meminfo' }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function memoryDecision(snapshot, { blocked = false, env = process.env } = {}) {
|
|
30
|
-
if (env.TP_MEMORY_GUARD_OFF === '1') return { ok: true, blocked: false, source: 'disabled' }
|
|
31
|
-
if (!snapshot) return { ok: true, blocked: false, source: 'unavailable' }
|
|
32
|
-
|
|
33
|
-
const blockPercent = Math.max(1, finiteNumber(env.TP_MEMORY_BLOCK_PERCENT, DEFAULT_BLOCK_PERCENT))
|
|
34
|
-
const releasePercent = Math.max(blockPercent + 1, finiteNumber(env.TP_MEMORY_RELEASE_PERCENT, DEFAULT_RELEASE_PERCENT))
|
|
35
|
-
const blockBytes = Math.max(64 * MIB, finiteNumber(env.TP_MEMORY_BLOCK_MIB, DEFAULT_BLOCK_BYTES / MIB) * MIB)
|
|
36
|
-
const releaseBytes = Math.max(blockBytes + MIB, finiteNumber(env.TP_MEMORY_RELEASE_MIB, DEFAULT_RELEASE_BYTES / MIB) * MIB)
|
|
37
|
-
const availableBytes = Number.isFinite(snapshot.availableBytes) ? snapshot.availableBytes : null
|
|
38
|
-
const totalBytes = Number.isFinite(snapshot.totalBytes) && snapshot.totalBytes > 0 ? snapshot.totalBytes : null
|
|
39
|
-
const availablePercent = Number.isFinite(snapshot.availablePercent)
|
|
40
|
-
? snapshot.availablePercent
|
|
41
|
-
: (availableBytes != null && totalBytes ? (availableBytes / totalBytes) * 100 : null)
|
|
42
|
-
|
|
43
|
-
// macOS' memory_pressure percentage already accounts for reclaimable/compressed
|
|
44
|
-
// memory. Generic os.freemem() does not, so a healthy Mac can look ~97% full.
|
|
45
|
-
// Other platforms need BOTH a low ratio and a low absolute reserve to avoid
|
|
46
|
-
// rejecting large-memory hosts merely because their page cache is busy.
|
|
47
|
-
const pressureSignal = snapshot.source === 'macos-memory-pressure'
|
|
48
|
-
const shouldBlock = pressureSignal
|
|
49
|
-
? availablePercent != null && availablePercent <= blockPercent
|
|
50
|
-
: availablePercent != null && availableBytes != null && availablePercent <= blockPercent && availableBytes <= blockBytes
|
|
51
|
-
const recovered = pressureSignal
|
|
52
|
-
? availablePercent != null && availablePercent >= releasePercent
|
|
53
|
-
: availablePercent != null && availableBytes != null && (availablePercent >= releasePercent || availableBytes >= releaseBytes)
|
|
54
|
-
const nextBlocked = blocked ? !recovered : shouldBlock
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
ok: !nextBlocked,
|
|
58
|
-
blocked: nextBlocked,
|
|
59
|
-
source: snapshot.source || 'unknown',
|
|
60
|
-
availablePercent,
|
|
61
|
-
availableBytes,
|
|
62
|
-
blockPercent,
|
|
63
|
-
releasePercent,
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function createHostMemoryGuard({
|
|
68
|
-
platform = process.platform,
|
|
69
|
-
env = process.env,
|
|
70
|
-
execFile = execFileSync,
|
|
71
|
-
readFile = fs.readFileSync,
|
|
72
|
-
freeMemory = os.freemem,
|
|
73
|
-
totalMemory = os.totalmem,
|
|
74
|
-
now = Date.now,
|
|
75
|
-
cacheMs = 5000,
|
|
76
|
-
} = {}) {
|
|
77
|
-
let cached = null
|
|
78
|
-
let cachedAt = 0
|
|
79
|
-
let blocked = false
|
|
80
|
-
|
|
81
|
-
const sample = () => {
|
|
82
|
-
if (platform === 'darwin') {
|
|
83
|
-
try {
|
|
84
|
-
const output = execFile('/usr/bin/memory_pressure', ['-Q'], { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
85
|
-
const availablePercent = parseMacMemoryPressure(output)
|
|
86
|
-
if (availablePercent != null) return { availablePercent, source: 'macos-memory-pressure' }
|
|
87
|
-
} catch { /* fall through to a portable snapshot */ }
|
|
88
|
-
} else if (platform === 'linux') {
|
|
89
|
-
try {
|
|
90
|
-
const parsed = parseLinuxMemAvailable(readFile('/proc/meminfo', 'utf8'))
|
|
91
|
-
if (parsed) return parsed
|
|
92
|
-
} catch { /* fall through to a portable snapshot */ }
|
|
93
|
-
}
|
|
94
|
-
try {
|
|
95
|
-
return { availableBytes: freeMemory(), totalBytes: totalMemory(), source: 'os-freemem' }
|
|
96
|
-
} catch { return null }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return function admitHostMemory(activity = 'start new work') {
|
|
100
|
-
const at = now()
|
|
101
|
-
if (!cached || at - cachedAt >= cacheMs) {
|
|
102
|
-
const decision = memoryDecision(sample(), { blocked, env })
|
|
103
|
-
blocked = decision.blocked
|
|
104
|
-
cached = decision
|
|
105
|
-
cachedAt = at
|
|
106
|
-
}
|
|
107
|
-
if (cached.ok) return cached
|
|
108
|
-
const pct = cached.availablePercent == null ? 'critically low' : `${Math.round(cached.availablePercent)}% available`
|
|
109
|
-
return {
|
|
110
|
-
...cached,
|
|
111
|
-
reason: `Host memory is critically low (${pct}). ThinkPool did not ${activity}. Existing lanes stay connected; close a lane or free memory, then retry.`,
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export const hostMemoryAdmission = createHostMemoryGuard()
|
|
1
|
+
import e from"node:os";import r from"node:fs";import{execFileSync as t}from"node:child_process";const o=1048576;function n(e,r){const t=Number(e);return Number.isFinite(t)?t:r}export function parseMacMemoryPressure(e){const r=String(e||"").match(/System-wide memory free percentage:\s*([0-9]+(?:\.[0-9]+)?)%/i);return r?Number(r[1]):null}export function parseLinuxMemAvailable(e){const r=String(e||""),t=r.match(/^MemAvailable:\s*(\d+)\s*kB\s*$/im),o=r.match(/^MemTotal:\s*(\d+)\s*kB\s*$/im);return t&&o?{availableBytes:1024*Number(t[1]),totalBytes:1024*Number(o[1]),source:"proc-meminfo"}:null}export function memoryDecision(e,{blocked:r=!1,env:t=process.env}={}){if("1"===t.TP_MEMORY_GUARD_OFF)return{ok:!0,blocked:!1,source:"disabled"};if(!e)return{ok:!0,blocked:!1,source:"unavailable"};const l=Math.max(1,n(t.TP_MEMORY_BLOCK_PERCENT,8)),a=Math.max(l+1,n(t.TP_MEMORY_RELEASE_PERCENT,12)),s=Math.max(64*o,n(t.TP_MEMORY_BLOCK_MIB,512)*o),i=Math.max(s+o,n(t.TP_MEMORY_RELEASE_MIB,1024)*o),c=Number.isFinite(e.availableBytes)?e.availableBytes:null,u=Number.isFinite(e.totalBytes)&&e.totalBytes>0?e.totalBytes:null,m=Number.isFinite(e.availablePercent)?e.availablePercent:null!=c&&u?c/u*100:null,y="macos-memory-pressure"===e.source,b=r?!(y?null!=m&&m>=a:null!=m&&null!=c&&(m>=a||c>=i)):y?null!=m&&m<=l:null!=m&&null!=c&&m<=l&&c<=s;return{ok:!b,blocked:b,source:e.source||"unknown",availablePercent:m,availableBytes:c,blockPercent:l,releasePercent:a}}export function createHostMemoryGuard({platform:o=process.platform,env:n=process.env,execFile:l=t,readFile:a=r.readFileSync,freeMemory:s=e.freemem,totalMemory:i=e.totalmem,now:c=Date.now,cacheMs:u=5e3}={}){let m=null,y=0,b=!1;return function(e="start new work"){const r=c();if(!m||r-y>=u){const e=memoryDecision((()=>{if("darwin"===o)try{const e=parseMacMemoryPressure(l("/usr/bin/memory_pressure",["-Q"],{encoding:"utf8",timeout:1500,stdio:["ignore","pipe","ignore"]}));if(null!=e)return{availablePercent:e,source:"macos-memory-pressure"}}catch{}else if("linux"===o)try{const e=parseLinuxMemAvailable(a("/proc/meminfo","utf8"));if(e)return e}catch{}try{return{availableBytes:s(),totalBytes:i(),source:"os-freemem"}}catch{return null}})(),{blocked:b,env:n});b=e.blocked,m=e,y=r}if(m.ok)return m;const t=null==m.availablePercent?"critically low":`${Math.round(m.availablePercent)}% available`;return{...m,reason:`Host memory is critically low (${t}). ThinkPool did not ${e}. Existing lanes stay connected; close a lane or free memory, then retry.`}}}export const hostMemoryAdmission=createHostMemoryGuard();
|
package/interrupted-resume.mjs
CHANGED
|
@@ -1,95 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Claude owns a long-lived input stream: its continue must wait for the init system
|
|
4
|
-
// event proving that stream is consumed. Codex is one process per turn and has no idle
|
|
5
|
-
// init event: sending the prompt IS what launches the resumed process.
|
|
6
|
-
|
|
7
|
-
export function sendInterruptedContinue(entry) {
|
|
8
|
-
if (!entry) return false
|
|
9
|
-
entry.pendingAutoResume = false
|
|
10
|
-
try { return entry.session?.sendTurn?.('continue') !== false }
|
|
11
|
-
catch { return false }
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// A text recap is the fallback when the native thread cannot be resumed. Claude
|
|
15
|
-
// already has a live init event to trigger this; Codex does not -- sending the
|
|
16
|
-
// recap is what starts its fresh exec process. Consume atomically, but restore it
|
|
17
|
-
// if delivery was refused so the next human turn can still prepend the context.
|
|
18
|
-
export function dispatchPendingRecap(entry) {
|
|
19
|
-
const recap = typeof entry?.pendingRecap === 'string' ? entry.pendingRecap : ''
|
|
20
|
-
if (!recap.trim()) return false
|
|
21
|
-
entry.pendingRecap = null
|
|
22
|
-
try { entry.flush?.() } catch { /* best effort */ }
|
|
23
|
-
try {
|
|
24
|
-
if (entry.session?.sendTurn?.(recap) === false) {
|
|
25
|
-
entry.pendingRecap = recap
|
|
26
|
-
return false
|
|
27
|
-
}
|
|
28
|
-
return true
|
|
29
|
-
} catch {
|
|
30
|
-
entry.pendingRecap = recap
|
|
31
|
-
return false
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function armInterruptedResume(entry) {
|
|
36
|
-
if (!entry?.session) return 'unavailable'
|
|
37
|
-
if (entry.runtime === 'codex') return sendInterruptedContinue(entry) ? 'sent' : 'unavailable'
|
|
38
|
-
entry.pendingAutoResume = true
|
|
39
|
-
return 'armed'
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function recoverInterruptedTurn(entry, { resumable = false, recap = '' } = {}) {
|
|
43
|
-
if (!entry?.session) return 'unavailable'
|
|
44
|
-
if (resumable) return armInterruptedResume(entry)
|
|
45
|
-
if (!String(recap || '').trim()) return 'unavailable'
|
|
46
|
-
entry.pendingRecap = String(recap)
|
|
47
|
-
if (entry.runtime === 'codex') return dispatchPendingRecap(entry) ? 'recap-sent' : 'unavailable'
|
|
48
|
-
return 'recap-armed'
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function isMissingResumeError(message) {
|
|
52
|
-
return /No conversation found|no rollout found for thread id|thread\/resume[^\n]*failed[^\n]*no rollout/i.test(String(message || ''))
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function shouldAttemptNativeResume(record, { canResume = () => false, codexThreadCanResume = () => true } = {}) {
|
|
56
|
-
if (!record?.sessionId) return false
|
|
57
|
-
// Hermes has an explicit, non-replaying ACP resume request that fails loudly
|
|
58
|
-
// when identity is gone. Always ask the runtime instead of silently aging the
|
|
59
|
-
// identity out under Claude's historical local freshness heuristic.
|
|
60
|
-
if (record.runtime === 'hermes') return true
|
|
61
|
-
return canResume(record) && (record.runtime !== 'codex' || codexThreadCanResume(record.sessionId))
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Bound the stale-native-thread self-heal at the ownership seam. The old entry is
|
|
65
|
-
// marked before teardown/reopen so duplicate error delivery can never fork another
|
|
66
|
-
// process. `reopen` returns the new structured entry; Codex consumes the recap
|
|
67
|
-
// immediately because a fresh exec has no idle init event.
|
|
68
|
-
export function recoverMissingResumeOnce(entry, { message, recap = '', reopen } = {}) {
|
|
69
|
-
// Hermes ACP session identity is part of the lane contract. If native resume
|
|
70
|
-
// fails, surfacing that failure is safer than silently replacing the session
|
|
71
|
-
// with a recap-backed fresh identity.
|
|
72
|
-
if (!entry || entry.runtime === 'hermes' || entry.recovered || !isMissingResumeError(message)) return { recovered: false, delivery: 'ignored' }
|
|
73
|
-
entry.recovered = true
|
|
74
|
-
try { entry.session?.end?.() } catch { /* already dead */ }
|
|
75
|
-
let recovered = null
|
|
76
|
-
try { recovered = reopen?.(String(recap || '')) || null } catch { /* caller surfaces reopen failure */ }
|
|
77
|
-
if (!recovered) return { recovered: true, delivery: 'unavailable', entry: null }
|
|
78
|
-
return {
|
|
79
|
-
recovered: true,
|
|
80
|
-
delivery: recoverInterruptedTurn(recovered, { resumable: false, recap }),
|
|
81
|
-
entry: recovered,
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// A real person speaking always wins the race. Otherwise a human turn that starts a
|
|
86
|
-
// cold Claude/Codex runtime can trigger its init event and enqueue a stale second
|
|
87
|
-
// `continue` behind the person's actual request.
|
|
88
|
-
export function supersedeInterruptedResume(entry) {
|
|
89
|
-
if (!entry) return false
|
|
90
|
-
const queued = !!entry.pendingInterruptedRecovery
|
|
91
|
-
entry.pendingInterruptedRecovery = null
|
|
92
|
-
if (!entry.pendingAutoResume) return queued
|
|
93
|
-
entry.pendingAutoResume = false
|
|
94
|
-
return true
|
|
95
|
-
}
|
|
1
|
+
export function sendInterruptedContinue(e){if(!e)return!1;e.pendingAutoResume=!1;try{return!1!==e.session?.sendTurn?.("continue")}catch{return!1}}export function dispatchPendingRecap(e){const n="string"==typeof e?.pendingRecap?e.pendingRecap:"";if(!n.trim())return!1;e.pendingRecap=null;try{e.flush?.()}catch{}try{return!1!==e.session?.sendTurn?.(n)||(e.pendingRecap=n,!1)}catch{return e.pendingRecap=n,!1}}export function armInterruptedResume(e){return e?.session?"codex"===e.runtime?sendInterruptedContinue(e)?"sent":"unavailable":(e.pendingAutoResume=!0,"armed"):"unavailable"}export function recoverInterruptedTurn(e,{resumable:n=!1,recap:r=""}={}){return e?.session?n?armInterruptedResume(e):String(r||"").trim()?(e.pendingRecap=String(r),"codex"===e.runtime?dispatchPendingRecap(e)?"recap-sent":"unavailable":"recap-armed"):"unavailable":"unavailable"}export function isMissingResumeError(e){return/No conversation found|no rollout found for thread id|thread\/resume[^\n]*failed[^\n]*no rollout/i.test(String(e||""))}export function shouldAttemptNativeResume(e,{canResume:n=()=>!1,codexThreadCanResume:r=()=>!0}={}){return!!e?.sessionId&&("hermes"===e.runtime||n(e)&&("codex"!==e.runtime||r(e.sessionId)))}export function recoverMissingResumeOnce(e,{message:n,recap:r="",reopen:t}={}){if(!e||"hermes"===e.runtime||e.recovered||!isMissingResumeError(n))return{recovered:!1,delivery:"ignored"};e.recovered=!0;try{e.session?.end?.()}catch{}let u=null;try{u=t?.(String(r||""))||null}catch{}return u?{recovered:!0,delivery:recoverInterruptedTurn(u,{resumable:!1,recap:r}),entry:u}:{recovered:!0,delivery:"unavailable",entry:null}}export function supersedeInterruptedResume(e){if(!e)return!1;const n=!!e.pendingInterruptedRecovery;return e.pendingInterruptedRecovery=null,e.pendingAutoResume?(e.pendingAutoResume=!1,!0):n}
|
package/keep-awake.mjs
CHANGED
|
@@ -1,148 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
import e from"node:os";import r from"node:fs";import t from"node:path";import{spawn as n}from"node:child_process";import{fileURLToPath as o}from"node:url";function i(r=e.homedir()){return t.join(r,".thinkpool-pair","settings.json")}export function loadKeepAwakePreference({home:t=e.homedir(),fsImpl:n=r}={}){try{return!0===JSON.parse(n.readFileSync(i(t),"utf8")).keepAwake}catch{return!1}}export function saveKeepAwakePreference(n,{home:o=e.homedir(),fsImpl:a=r}={}){const s=i(o),l=t.dirname(s);let c={};try{c=JSON.parse(a.readFileSync(s,"utf8"))||{}}catch{}const p={...c,keepAwake:!0===n},u=`${s}.tmp.${process.pid}.${Date.now()}`;try{return a.mkdirSync(l,{recursive:!0}),a.writeFileSync(u,JSON.stringify(p,null,2)+"\n",{mode:384}),a.renameSync(u,s),!0}catch{try{a.rmSync(u,{force:!0})}catch{}return!1}}export function explicitKeepAwakeChoice(e=[]){const r=e.indexOf("--"),t=r>=0?e.slice(0,r):e;return!t.includes("--no-keep-awake")&&(!!t.includes("--keep-awake")||null)}export function keepAwakeEnabled(e=[],{env:r=process.env,loadPreference:t=loadKeepAwakePreference}={}){const n=explicitKeepAwakeChoice(e);return null!==n?n:"1"===r.THINKPOOL_PAIR_KEEP_AWAKE||"0"!==r.THINKPOOL_PAIR_KEEP_AWAKE&&!0===t()}const a=e=>['$ErrorActionPreference = "Stop"',"Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ThinkpoolPower { [DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint flags); }'","[ThinkpoolPower]::SetThreadExecutionState([uint32]0x80000001) | Out-Null",`try { while (Get-Process -Id ${e} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 5 } }`,"finally { [ThinkpoolPower]::SetThreadExecutionState([uint32]0x80000000) | Out-Null }"].join("; ");export function keepAwakeCommand(e,r,{node:t=process.execPath,modulePath:n=o(import.meta.url)}={}){if(!Number.isInteger(r)||r<=0)throw new Error("keep-awake requires a live bridge pid");return"darwin"===e?{command:"/usr/bin/caffeinate",args:["-i","-w",String(r)],adapter:"caffeinate"}:"linux"===e?{command:"systemd-inhibit",args:["--what=sleep","--mode=block","--who=Thinkpool","--why=Keep the Thinkpool bridge reachable","--",t,n,"--wait-for",String(r)],adapter:"systemd-inhibit"}:"win32"===e?{command:"powershell.exe",args:["-NoLogo","-NoProfile","-NonInteractive","-WindowStyle","Hidden","-Command",a(r)],adapter:"Windows power request"}:null}export function startKeepAwake({enabled:e,platform:r=process.platform,parentPid:t=process.pid,spawnImpl:o=n,stderr:i=process.stderr,accountChild:a="1"===process.env.THINKPOOL_PAIR_ACCOUNT_CHILD}={}){if(!e||a)return null;let s,l;try{s=keepAwakeCommand(r,t)}catch(e){return i.write(` ⚠ keep-awake unavailable: ${e?.message||e}\n`),null}if(!s)return i.write(` ⚠ keep-awake is not supported on ${r}; bridge startup will continue normally.\n`),null;const c=Date.now();try{return l=o(s.command,s.args,{stdio:"ignore",windowsHide:!0}),l.once?.("spawn",()=>{i.write(` ◆ keep-awake ON via ${s.adapter}: system sleep is inhibited while this bridge runs.\n Your screen can still dim, lock, and turn off normally.\n`)}),l.once?.("error",e=>{i.write(` ⚠ keep-awake could not start (${s.adapter}: ${e?.message||e}); bridge startup will continue normally.\n`)}),l.once?.("exit",e=>{e&&Date.now()-c<3e4&&i.write(` ⚠ keep-awake helper exited early (${s.adapter}, code ${e}); the computer may sleep normally.\n`)}),l.unref?.(),l}catch(e){return i.write(` ⚠ keep-awake could not start (${s.adapter}: ${e?.message||e}); bridge startup will continue normally.\n`),null}}if(process.argv[1]&&t.resolve(process.argv[1])===o(import.meta.url)&&"--wait-for"===process.argv[2]){const s=Number(process.argv[3]);Number.isInteger(s)&&s>0&&await async function(e){for(;;){try{process.kill(e,0)}catch{return}await new Promise(e=>setTimeout(e,5e3))}}(s)}
|
package/key-shape.mjs
CHANGED
|
@@ -1,49 +1 @@
|
|
|
1
|
-
|
|
2
|
-
it is safe to hand to a client.
|
|
3
|
-
|
|
4
|
-
Deliberately dependency-free (not even node: builtins beyond Buffer) so it can
|
|
5
|
-
be imported from BOTH sides of the tarball boundary: the published
|
|
6
|
-
`thinkpool-pair` bridge and the Vercel serverless function api/config.js.
|
|
7
|
-
|
|
8
|
-
Supabase runs two key generations side by side (see
|
|
9
|
-
docs/specs/2026-07-10-supabase-key-migration.md §1a):
|
|
10
|
-
|
|
11
|
-
legacy a JWT — `eyJ…` — whose `role` payload claim is `anon` or `service_role`
|
|
12
|
-
new an opaque string — `sb_publishable_…` or `sb_secret_…`
|
|
13
|
-
|
|
14
|
-
Exactly two of those four are client-safe. The other two bypass RLS. Nothing
|
|
15
|
-
here VERIFIES a signature — that is Supabase's job, over the network, and we
|
|
16
|
-
have no key material to verify with (§4: nothing in this repo verifies a JWT).
|
|
17
|
-
We are reading a shape to answer one question: "could this possibly be a
|
|
18
|
-
secret?" A false "yes" costs a fallback. A false "no" costs the database. */
|
|
19
|
-
|
|
20
|
-
/** @returns {'publishable'|'secret'|'anon-jwt'|'service-jwt'|'invalid'} */
|
|
21
|
-
export function classifyKey(key) {
|
|
22
|
-
if (typeof key !== 'string') return 'invalid'
|
|
23
|
-
const k = key.trim()
|
|
24
|
-
if (!k) return 'invalid'
|
|
25
|
-
|
|
26
|
-
// New-generation keys are self-describing. Check `secret` first: a prefix
|
|
27
|
-
// test that fails open is exactly the bug this module exists to prevent.
|
|
28
|
-
if (k.startsWith('sb_secret_')) return 'secret'
|
|
29
|
-
if (k.startsWith('sb_publishable_')) return 'publishable'
|
|
30
|
-
|
|
31
|
-
// Legacy keys are unsigned-to-us JWTs; the role lives in the payload.
|
|
32
|
-
if (k.startsWith('eyJ')) {
|
|
33
|
-
const parts = k.split('.')
|
|
34
|
-
if (parts.length !== 3) return 'invalid'
|
|
35
|
-
try {
|
|
36
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
|
|
37
|
-
if (payload.role === 'anon') return 'anon-jwt'
|
|
38
|
-
if (payload.role === 'service_role') return 'service-jwt'
|
|
39
|
-
return 'invalid' // an unknown role is not a role we will trust
|
|
40
|
-
} catch { return 'invalid' }
|
|
41
|
-
}
|
|
42
|
-
return 'invalid'
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Only a publishable key or a legacy anon JWT may ever reach a client. */
|
|
46
|
-
export function isClientSafeKey(key) {
|
|
47
|
-
const c = classifyKey(key)
|
|
48
|
-
return c === 'publishable' || c === 'anon-jwt'
|
|
49
|
-
}
|
|
1
|
+
export function classifyKey(t){if("string"!=typeof t)return"invalid";const r=t.trim();if(!r)return"invalid";if(r.startsWith("sb_secret_"))return"secret";if(r.startsWith("sb_publishable_"))return"publishable";if(r.startsWith("eyJ")){const t=r.split(".");if(3!==t.length)return"invalid";try{const r=JSON.parse(Buffer.from(t[1],"base64url").toString("utf8"));return"anon"===r.role?"anon-jwt":"service_role"===r.role?"service-jwt":"invalid"}catch{return"invalid"}}return"invalid"}export function isClientSafeKey(t){const r=classifyKey(t);return"publishable"===r||"anon-jwt"===r}
|