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.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
package/hermes-setup.mjs CHANGED
@@ -1,167 +1 @@
1
- import fs from 'node:fs'
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 'node:os'
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();
@@ -1,95 +1 @@
1
- // Runtime-specific bridge-restart recovery.
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
- /* 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
- }
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
- /* key-shape.mjs classify a Supabase API key by its SHAPE, and decide whether
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}