@twigpine/openclaude 0.0.0-stage → 0.32.0

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.
@@ -0,0 +1,220 @@
1
+ import os from 'node:os'
2
+
3
+ export const HEAP_SIZE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB'
4
+ export const HEAP_PERCENTAGE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_PERCENTAGE'
5
+ export const DEFAULT_HEAP_SIZE_MB = 8192
6
+ export const HEAP_SIZE_FLAG = '--max-old-space-size'
7
+ export const HEAP_PERCENTAGE_FLAG = '--max-old-space-size-percentage'
8
+ export const MAX_MEMORY_FLAG = '--max-memory'
9
+
10
+ /** @typedef {{ constrainedMemory?: () => unknown, totalmem?: () => unknown }} MemorySources */
11
+
12
+ /**
13
+ * @param {string[]} args
14
+ * @param {string} flag
15
+ */
16
+ export function hasNodeFlag(args, flag) {
17
+ return args.some(arg => arg === flag || arg.startsWith(`${flag}=`))
18
+ }
19
+
20
+ /**
21
+ * @param {string | undefined} nodeOptions
22
+ */
23
+ export function nodeOptionArgs(nodeOptions) {
24
+ return (nodeOptions || '').split(/\s+/).filter(Boolean)
25
+ }
26
+
27
+ /**
28
+ * @param {string[]} args
29
+ */
30
+ export function hasHeapLimitFlag(args) {
31
+ return hasNodeFlag(args, HEAP_SIZE_FLAG) || hasNodeFlag(args, HEAP_PERCENTAGE_FLAG)
32
+ }
33
+
34
+ /**
35
+ * @param {unknown} raw
36
+ * @returns {number | null}
37
+ */
38
+ export function parsePositiveIntegerMb(raw) {
39
+ if (raw == null || raw === '') return null
40
+ const parsed = Number.parseInt(String(raw), 10)
41
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
42
+ }
43
+
44
+ /**
45
+ * Node accepts a number greater than 0 and up to 100.
46
+ * @param {unknown} raw
47
+ * @returns {number | null}
48
+ */
49
+ export function parsePercentage(raw) {
50
+ if (raw == null) return null
51
+ const trimmed = String(raw).trim()
52
+ if (!trimmed) return null
53
+ const withoutPercent = trimmed.endsWith('%') ? trimmed.slice(0, -1) : trimmed
54
+ if (withoutPercent.trim() === '') return null
55
+ const parsed = Number(withoutPercent)
56
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) return null
57
+ return parsed
58
+ }
59
+
60
+ /**
61
+ * Prefer cgroup/OS memory constraints so container limits win over host RAM.
62
+ * @param {MemorySources} [sources]
63
+ */
64
+ export function getAvailableMemoryBytes(sources = {}) {
65
+ const constrainedMemory =
66
+ sources.constrainedMemory ??
67
+ (() => {
68
+ try {
69
+ return typeof process.constrainedMemory === 'function'
70
+ ? process.constrainedMemory()
71
+ : undefined
72
+ } catch {
73
+ return undefined
74
+ }
75
+ })
76
+ const totalmem = sources.totalmem ?? (() => os.totalmem())
77
+
78
+ const constrained = constrainedMemory()
79
+ // Node/libuv reports UINT64_MAX (not a safe integer in JS) when there is no
80
+ // cgroup/OS memory limit. Treat only real byte caps as constraints.
81
+ if (typeof constrained === 'number' && Number.isSafeInteger(constrained) && constrained > 0) {
82
+ return constrained
83
+ }
84
+ const total = totalmem()
85
+ if (typeof total === 'number' && Number.isFinite(total) && total > 0) {
86
+ return total
87
+ }
88
+ return 0
89
+ }
90
+
91
+ /**
92
+ * @param {number} percentage
93
+ * @param {number} availableBytes
94
+ * @returns {number | null}
95
+ */
96
+ export function heapSizeMbFromPercentage(percentage, availableBytes) {
97
+ if (!(percentage > 0 && percentage <= 100) || !(availableBytes > 0)) return null
98
+ const mb = Math.floor((availableBytes * (percentage / 100)) / (1024 * 1024))
99
+ return Math.max(1, mb)
100
+ }
101
+
102
+ /**
103
+ * @param {string[]} args
104
+ * @param {string} flag
105
+ * @returns {string | null}
106
+ */
107
+ export function findEqualsFlagValue(args, flag) {
108
+ const prefix = `${flag}=`
109
+ for (const arg of args) {
110
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length)
111
+ }
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * @param {string[]} args
117
+ * @param {string} flag
118
+ * @returns {string | null}
119
+ */
120
+ export function findEqualsOrNextFlagValue(args, flag) {
121
+ const prefix = `${flag}=`
122
+ for (let i = 0; i < args.length; i++) {
123
+ const arg = args[i]
124
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length)
125
+ if (arg === flag) {
126
+ const next = args[i + 1]
127
+ if (next && !next.startsWith('-')) return next
128
+ return ''
129
+ }
130
+ }
131
+ return null
132
+ }
133
+
134
+ /**
135
+ * Strip launcher-only heap args so Commander does not reject them.
136
+ * `--max-memory` stays equals-only to match the previous launcher contract.
137
+ * @param {string[]} args
138
+ */
139
+ export function stripLauncherHeapArgs(args) {
140
+ const stripped = []
141
+ for (let i = 0; i < args.length; i++) {
142
+ const arg = args[i]
143
+ if (arg === MAX_MEMORY_FLAG || arg.startsWith(`${MAX_MEMORY_FLAG}=`)) continue
144
+ if (arg.startsWith(`${HEAP_PERCENTAGE_FLAG}=`)) continue
145
+ if (arg === HEAP_PERCENTAGE_FLAG) {
146
+ const next = args[i + 1]
147
+ if (next && !next.startsWith('-') && parsePercentage(next) != null) i += 1
148
+ continue
149
+ }
150
+ stripped.push(arg)
151
+ }
152
+ return stripped
153
+ }
154
+
155
+ /**
156
+ * @param {{
157
+ * argv?: string[],
158
+ * env?: NodeJS.ProcessEnv,
159
+ * availableBytes?: number,
160
+ * memorySources?: MemorySources,
161
+ * }} [input]
162
+ */
163
+ export function resolveHeapSizeMb(input = {}) {
164
+ const argv = input.argv ?? []
165
+ const env = input.env ?? {}
166
+ const availableBytes =
167
+ input.availableBytes ?? getAvailableMemoryBytes(input.memorySources)
168
+
169
+ const maxMem = parsePositiveIntegerMb(findEqualsFlagValue(argv, MAX_MEMORY_FLAG))
170
+ if (maxMem != null) {
171
+ return { mb: maxMem, source: 'max-memory', setMaxMemoryEnv: true }
172
+ }
173
+
174
+ const argvPercentage = parsePercentage(
175
+ findEqualsOrNextFlagValue(argv, HEAP_PERCENTAGE_FLAG),
176
+ )
177
+ if (argvPercentage != null) {
178
+ const mb = heapSizeMbFromPercentage(argvPercentage, availableBytes)
179
+ if (mb != null) {
180
+ return { mb, source: 'argv-percentage', percentage: argvPercentage }
181
+ }
182
+ // Valid percentage requested; memory size unknown. Do not treat this as
183
+ // "no percentage" and fall through to an unrelated MB env value.
184
+ return {
185
+ mb: DEFAULT_HEAP_SIZE_MB,
186
+ source: 'percentage-unavailable',
187
+ percentage: argvPercentage,
188
+ }
189
+ }
190
+
191
+ const envPercentage = parsePercentage(env[HEAP_PERCENTAGE_ENV])
192
+ if (envPercentage != null) {
193
+ const mb = heapSizeMbFromPercentage(envPercentage, availableBytes)
194
+ if (mb != null) {
195
+ return { mb, source: 'env-percentage', percentage: envPercentage }
196
+ }
197
+ return {
198
+ mb: DEFAULT_HEAP_SIZE_MB,
199
+ source: 'percentage-unavailable',
200
+ percentage: envPercentage,
201
+ }
202
+ }
203
+
204
+ const envMb = parsePositiveIntegerMb(env[HEAP_SIZE_ENV])
205
+ if (envMb != null) return { mb: envMb, source: 'env-mb' }
206
+
207
+ return { mb: DEFAULT_HEAP_SIZE_MB, source: 'default' }
208
+ }
209
+
210
+ /**
211
+ * One-line operator warning when a valid percentage cannot be converted.
212
+ * @param {number} percentage
213
+ * @param {number} [fallbackMb]
214
+ */
215
+ export function formatPercentageUnavailableStderr(
216
+ percentage,
217
+ fallbackMb = DEFAULT_HEAP_SIZE_MB,
218
+ ) {
219
+ return `openclaude: could not convert heap percentage ${percentage} to megabytes because available memory is unknown; using ${fallbackMb}`
220
+ }
@@ -0,0 +1,13 @@
1
+ import { join, win32 } from 'path'
2
+ import { pathToFileURL } from 'url'
3
+
4
+ export function getDistImportSpecifier(baseDir) {
5
+ if (/^[A-Za-z]:\\/.test(baseDir)) {
6
+ const distPath = win32.join(baseDir, '..', 'dist', 'cli.mjs')
7
+ return `file:///${distPath.replace(/\\/g, '/')}`
8
+ }
9
+
10
+ const joinImpl = join
11
+ const distPath = joinImpl(baseDir, '..', 'dist', 'cli.mjs')
12
+ return pathToFileURL(distPath).href
13
+ }
@@ -0,0 +1,13 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { getDistImportSpecifier } from './import-specifier.mjs'
5
+
6
+ test('builds a file URL import specifier for dist/cli.mjs', () => {
7
+ const specifier = getDistImportSpecifier('C:\\repo\\bin')
8
+
9
+ assert.equal(
10
+ specifier,
11
+ 'file:///C:/repo/dist/cli.mjs',
12
+ )
13
+ })
@@ -0,0 +1,17 @@
1
+ import * as nodeModule from 'node:module'
2
+
3
+ /** @typedef {{ enableCompileCache?: () => unknown }} CompileCacheModule */
4
+
5
+ /**
6
+ * @param {CompileCacheModule} [module=nodeModule]
7
+ */
8
+ export function enableNodeCompileCacheIfAvailable(module = nodeModule) {
9
+ const enable = module.enableCompileCache
10
+ if (typeof enable !== 'function') return
11
+
12
+ try {
13
+ enable()
14
+ } catch {
15
+ // Compile caching is optional and must never block startup.
16
+ }
17
+ }
package/bin/openclaude ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * OpenClaude — Claude Code with any LLM
5
+ *
6
+ * If dist/cli.mjs exists (built), run that.
7
+ * Otherwise, tell the user to build first or use `bun run dev`.
8
+ */
9
+
10
+ import { existsSync } from 'fs'
11
+ import { join, dirname } from 'path'
12
+ import { fileURLToPath, pathToFileURL } from 'url'
13
+ import { spawnSync } from 'child_process'
14
+ import { enableNodeCompileCacheIfAvailable } from './node-compile-cache.mjs'
15
+ import {
16
+ HEAP_SIZE_ENV,
17
+ HEAP_SIZE_FLAG,
18
+ formatPercentageUnavailableStderr,
19
+ hasHeapLimitFlag,
20
+ hasNodeFlag,
21
+ nodeOptionArgs,
22
+ resolveHeapSizeMb,
23
+ stripLauncherHeapArgs,
24
+ } from './heap-limit.mjs'
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url))
27
+ const distPath = join(__dirname, '..', 'dist', 'cli.mjs')
28
+
29
+ const HEAP_RELAUNCHED_ENV = 'OPENCLAUDE_HEAP_RELAUNCHED'
30
+ const DISABLE_HEAP_RELAUNCH_ENV = 'OPENCLAUDE_DISABLE_HEAP_RELAUNCH'
31
+
32
+ function currentNodeOptionArgs() {
33
+ return [...process.execArgv, ...nodeOptionArgs(process.env.NODE_OPTIONS)]
34
+ }
35
+
36
+ function applyResolvedHeapEnv(resolved) {
37
+ process.env[HEAP_SIZE_ENV] = String(resolved.mb)
38
+ if (resolved.setMaxMemoryEnv) {
39
+ process.env.OPENCLAUDE_MAX_MEMORY_MB = String(resolved.mb)
40
+ }
41
+ }
42
+
43
+ function replaceProcessArgvWithStrippedLauncherArgs() {
44
+ process.argv = [
45
+ process.argv[0],
46
+ process.argv[1],
47
+ ...stripLauncherHeapArgs(process.argv.slice(2)),
48
+ ]
49
+ }
50
+
51
+ function relaunchWithLongSessionHeapIfNeeded() {
52
+ if (process.env[DISABLE_HEAP_RELAUNCH_ENV] === '1') {
53
+ replaceProcessArgvWithStrippedLauncherArgs()
54
+ return
55
+ }
56
+ if (process.env[HEAP_RELAUNCHED_ENV] === '1') {
57
+ replaceProcessArgvWithStrippedLauncherArgs()
58
+ return
59
+ }
60
+ const nodeArgs = currentNodeOptionArgs()
61
+ const hasHeapLimit = hasHeapLimitFlag(nodeArgs)
62
+ const hasExplicitGc = hasNodeFlag(nodeArgs, '--expose-gc')
63
+ if (hasHeapLimit && hasExplicitGc) {
64
+ replaceProcessArgvWithStrippedLauncherArgs()
65
+ return
66
+ }
67
+
68
+ const execArgv = [...process.execArgv]
69
+ if (!hasHeapLimit) {
70
+ const resolved = resolveHeapSizeMb({
71
+ argv: process.argv.slice(2),
72
+ env: process.env,
73
+ })
74
+ if (resolved.source === 'percentage-unavailable') {
75
+ console.error(formatPercentageUnavailableStderr(resolved.percentage))
76
+ }
77
+ applyResolvedHeapEnv(resolved)
78
+ execArgv.push(`${HEAP_SIZE_FLAG}=${resolved.mb}`)
79
+ }
80
+
81
+ // Expose explicit GC for long interactive sessions. NODE_OPTIONS cannot
82
+ // carry --expose-gc, so the executable wrapper must add it before startup.
83
+ if (!hasExplicitGc) {
84
+ execArgv.push('--expose-gc')
85
+ }
86
+
87
+ // Strip launcher-only heap args before relaunching — Commander in the built
88
+ // CLI would reject them as unknown. The native Node percentage flag in
89
+ // execArgv/NODE_OPTIONS is left untouched because this process already
90
+ // started with a Node that accepted it.
91
+ const childArgs = stripLauncherHeapArgs(process.argv.slice(2))
92
+
93
+ // Preserve the original argv[1] (which may be a symlink like
94
+ // /usr/local/bin/openclaude) instead of resolving it via import.meta.url.
95
+ // Resolving symlinks here defeats install-type detection downstream: a real
96
+ // npm global install (symlink → node_modules/@twigpine/openclaude/bin) would
97
+ // resolve to the package's real path inside node_modules, which is fine, but
98
+ // a `npm install -g .` dev symlink resolves back to the repo and looks like
99
+ // a source-tree dev run. Using argv[1] keeps the invocation path stable so
100
+ // doctorDiagnostic's npm-global path markers can match correctly.
101
+ const launcherPath = process.argv[1] || fileURLToPath(import.meta.url)
102
+
103
+ const result = spawnSync(process.execPath, [
104
+ ...execArgv,
105
+ launcherPath,
106
+ ...childArgs,
107
+ ], {
108
+ stdio: 'inherit',
109
+ env: {
110
+ ...process.env,
111
+ [HEAP_RELAUNCHED_ENV]: '1',
112
+ },
113
+ })
114
+
115
+ if (result.error) {
116
+ console.error(`openclaude: failed to restart with long-session heap: ${result.error.message}`)
117
+ process.exit(1)
118
+ }
119
+
120
+ process.exit(result.status ?? 1)
121
+ }
122
+
123
+ if (existsSync(distPath)) {
124
+ relaunchWithLongSessionHeapIfNeeded()
125
+ enableNodeCompileCacheIfAvailable()
126
+ await import(pathToFileURL(distPath).href)
127
+ } else {
128
+ console.error(`
129
+ openclaude: dist/cli.mjs not found.
130
+
131
+ Build first:
132
+ bun run build
133
+
134
+ Or run directly with Bun:
135
+ bun run dev
136
+
137
+ See README.md for setup instructions.
138
+ `)
139
+ process.exit(1)
140
+ }