@phenx-inc/ctlsurf 0.17.3 → 0.17.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/ctlsurf-worker.js +243 -44
- package/out/headless/index.mjs +1 -1
- package/out/headless/index.mjs.map +1 -1
- package/out/main/index.js +5 -1
- package/package.json +1 -1
- package/scripts/postinstall.cjs +32 -3
- package/scripts/prepare-work-map.py +12 -0
- package/scripts/setup-ui.cjs +161 -0
- package/scripts/work-map-setup.cjs +127 -0
- package/src/main/index.ts +9 -1
package/out/main/index.js
CHANGED
|
@@ -24918,8 +24918,12 @@ const orchestrator = new Orchestrator(
|
|
|
24918
24918
|
updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd);
|
|
24919
24919
|
}
|
|
24920
24920
|
},
|
|
24921
|
-
{ appRoot:
|
|
24921
|
+
{ appRoot: workMapRoot(), desktop: true }
|
|
24922
24922
|
);
|
|
24923
|
+
function workMapRoot() {
|
|
24924
|
+
const resources = process.resourcesPath;
|
|
24925
|
+
return fs.existsSync(path$1.join(resources, "scripts", "work-map-clef.py")) ? resources : electron.app.getAppPath();
|
|
24926
|
+
}
|
|
24923
24927
|
function createWindow() {
|
|
24924
24928
|
const saved = orchestrator.settingsData;
|
|
24925
24929
|
if (saved.themeSource === "light" || saved.themeSource === "dark" || saved.themeSource === "system") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phenx-inc/ctlsurf",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
4
4
|
"description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
|
|
5
5
|
"main": "out/main/index.js",
|
|
6
6
|
"bin": {
|
package/scripts/postinstall.cjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
const fs = require('node:fs')
|
|
1
2
|
const { spawnSync } = require('node:child_process')
|
|
2
3
|
const { detectCapability } = require('./check-work-map-gpu.cjs')
|
|
4
|
+
const { banner, formatBytes, colors: { R, D, G, C } } = require('./setup-ui.cjs')
|
|
5
|
+
const { MODEL_BYTES } = require('./work-map-setup.cjs')
|
|
6
|
+
const { version } = require('../package.json')
|
|
3
7
|
|
|
4
8
|
// Keep the existing best-effort native rebuild, including on Windows.
|
|
5
9
|
try {
|
|
@@ -8,8 +12,33 @@ try {
|
|
|
8
12
|
})
|
|
9
13
|
} catch { /* The launcher also handles missing native builds. */ }
|
|
10
14
|
|
|
15
|
+
// npm hides lifecycle-script output (foreground-scripts=false), so the banner is written
|
|
16
|
+
// straight to the terminal. No terminal (CI, Windows, detached) → the plain log line only.
|
|
17
|
+
function openTty() {
|
|
18
|
+
if (process.platform === 'win32' || process.env.CI) return null
|
|
19
|
+
try { return fs.openSync('/dev/tty', 'w') } catch { return null }
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
detectCapability({ desktop: true }).then(capability => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
const available = capability.state === 'available'
|
|
24
|
+
const tty = openTty()
|
|
25
|
+
if (tty === null) {
|
|
26
|
+
console.log(available
|
|
27
|
+
? `[ctlsurf] Work map hardware detected: ${capability.gpuName}. GPU access is checked again by the desktop app.`
|
|
28
|
+
: `[ctlsurf] ${capability.reason} Other app features remain available.`)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const gpu = available
|
|
32
|
+
? `${G}✓${R} ${capability.gpuName} · Metal ${D}— work map capable${R}`
|
|
33
|
+
: `${D}– ${capability.reason}${R}`
|
|
34
|
+
const next = available
|
|
35
|
+
? `Electron runtime + optional work map model ${D}(Clef-Flash, ${formatBytes(MODEL_BYTES)})${R}`
|
|
36
|
+
: 'Electron runtime'
|
|
37
|
+
try {
|
|
38
|
+
fs.writeSync(tty, '\r\x1b[2K' + banner(version)
|
|
39
|
+
+ ` ${G}✓${R} installed\n ${gpu}\n\n`
|
|
40
|
+
+ ` Run ${C}ctlsurf${R} to finish setup: ${next}\n\n`)
|
|
41
|
+
} finally {
|
|
42
|
+
fs.closeSync(tty)
|
|
43
|
+
}
|
|
15
44
|
}).catch(() => {})
|
|
@@ -11,7 +11,13 @@ import sys
|
|
|
11
11
|
def main():
|
|
12
12
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
13
13
|
parser.add_argument('--directory', type=Path, default=Path.home() / '.ctlsurf' / 'clef-flash')
|
|
14
|
+
parser.add_argument('--progress', action='store_true', help='print ::phase markers for the ctlsurf launcher')
|
|
14
15
|
args = parser.parse_args()
|
|
16
|
+
|
|
17
|
+
def phase(name):
|
|
18
|
+
if args.progress:
|
|
19
|
+
print(f'::phase {name}', flush=True)
|
|
20
|
+
|
|
15
21
|
if sys.platform != 'darwin' or platform.machine() != 'arm64':
|
|
16
22
|
parser.error('Local Clef-Flash requires an Apple Silicon Mac')
|
|
17
23
|
root = args.directory.resolve()
|
|
@@ -20,10 +26,16 @@ def main():
|
|
|
20
26
|
model = root / 'model'
|
|
21
27
|
env = {**os.environ, 'UV_CACHE_DIR': str(root / 'uv'), 'HF_HOME': str(root / 'huggingface'),
|
|
22
28
|
'HF_HUB_DISABLE_TELEMETRY': '1', 'TOKENIZERS_PARALLELISM': 'false'}
|
|
29
|
+
if args.progress:
|
|
30
|
+
# The launcher measures bytes on disk; hf-xet only writes each file once it is complete.
|
|
31
|
+
env.update(HF_HUB_DISABLE_PROGRESS_BARS='1', HF_HUB_DISABLE_XET='1')
|
|
32
|
+
phase('env')
|
|
23
33
|
if not python.exists():
|
|
24
34
|
subprocess.run(['uv', 'venv', '--python', '3.12', str(root / 'env')], env=env, check=True)
|
|
35
|
+
phase('deps')
|
|
25
36
|
subprocess.run(['uv', 'pip', 'install', '--python', str(python), 'huggingface_hub',
|
|
26
37
|
'mlx==0.32.3', 'mlx-lm==0.32.0', 'mlx-vlm==0.7.4'], env=env, check=True)
|
|
38
|
+
phase('model')
|
|
27
39
|
subprocess.run([str(python), '-c',
|
|
28
40
|
'from huggingface_hub import snapshot_download; import sys; '
|
|
29
41
|
'snapshot_download("mlx-community/clef-flash-8bit", '
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Terminal UI shared by the npm postinstall banner and the launcher's first-run setup.
|
|
2
|
+
// Live redraw only on a TTY; otherwise each step prints one plain line when it settles.
|
|
3
|
+
|
|
4
|
+
const R = '\x1b[0m'
|
|
5
|
+
const D = '\x1b[90m'
|
|
6
|
+
const G = '\x1b[32m'
|
|
7
|
+
const Y = '\x1b[33m'
|
|
8
|
+
const C = '\x1b[36m'
|
|
9
|
+
|
|
10
|
+
const LOGO = [
|
|
11
|
+
'╔═╗╔╦╗╦ ╔═╗╦ ╦╦═╗╔═╗',
|
|
12
|
+
'║ ║ ║ ╚═╗║ ║╠╦╝╠╣ ',
|
|
13
|
+
'╚═╝ ╩ ╩═╝╚═╝╚═╝╩╚═╚ ',
|
|
14
|
+
]
|
|
15
|
+
const LOGO_COLORS = [51, 45, 39] // 256-color cyan → blue, one per row
|
|
16
|
+
|
|
17
|
+
function banner(version) {
|
|
18
|
+
const logo = LOGO.map((line, i) => ` \x1b[38;5;${LOGO_COLORS[i]}m${line}${R}`)
|
|
19
|
+
return ['', ...logo, ` ${D}the terminal that remembers · v${version}${R}`, '', ''].join('\n')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatBytes(n) {
|
|
23
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} GB`
|
|
24
|
+
if (n >= 1e6) return `${Math.round(n / 1e6)} MB`
|
|
25
|
+
return `${Math.round(n / 1e3)} KB`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatDuration(ms) {
|
|
29
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
30
|
+
const m = Math.floor(s / 60)
|
|
31
|
+
return m ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
|
35
|
+
const ICONS = {
|
|
36
|
+
pending: `${D}·${R}`, done: `${G}✓${R}`, fail: `${Y}✗${R}`,
|
|
37
|
+
warn: `${Y}!${R}`, skip: `${D}–${R}`, ask: `${C}?${R}`,
|
|
38
|
+
}
|
|
39
|
+
const SETTLED = new Set(['done', 'fail', 'warn', 'skip'])
|
|
40
|
+
const BAR = 16
|
|
41
|
+
const LABEL = 23
|
|
42
|
+
|
|
43
|
+
// progress: undefined → no bar, null → indeterminate sweep, 0..1 → filled bar with percent
|
|
44
|
+
function bar(progress, frame) {
|
|
45
|
+
if (progress === null) {
|
|
46
|
+
const span = BAR - 4
|
|
47
|
+
const pos = Math.abs((frame % (span * 2)) - span)
|
|
48
|
+
return `${D}▕${'░'.repeat(pos)}${C}▓██▓${D}${'░'.repeat(span - pos)}▏${R}`
|
|
49
|
+
}
|
|
50
|
+
const p = Math.min(1, Math.max(0, progress))
|
|
51
|
+
const n = Math.round(p * BAR)
|
|
52
|
+
return `${D}▕${C}${'█'.repeat(n)}${D}${'░'.repeat(BAR - n)}▏${R} ${String(Math.floor(p * 100)).padStart(3)}%`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Pad or truncate to a visible width, skipping ANSI escapes.
|
|
56
|
+
function fit(text, width) {
|
|
57
|
+
const parts = text.match(/\x1b\[[0-9;?]*[A-Za-z]|[\s\S]/gu) || []
|
|
58
|
+
const visible = parts.filter(p => p[0] !== '\x1b').length
|
|
59
|
+
let out = ''
|
|
60
|
+
let n = 0
|
|
61
|
+
for (const p of parts) {
|
|
62
|
+
if (p[0] === '\x1b') { out += p; continue }
|
|
63
|
+
if (visible > width && n === width - 1) { out += '…'; n++; break }
|
|
64
|
+
out += p
|
|
65
|
+
n++
|
|
66
|
+
}
|
|
67
|
+
return out + R + ' '.repeat(Math.max(0, width - n))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class Steps {
|
|
71
|
+
constructor(title, labels, out = process.stdout) {
|
|
72
|
+
this.title = title
|
|
73
|
+
this.out = out
|
|
74
|
+
this.live = !!out.isTTY
|
|
75
|
+
this.steps = labels.map(label => ({ label, state: 'pending', note: '', detail: '', progress: undefined, printed: false }))
|
|
76
|
+
this.drawn = 0
|
|
77
|
+
this.frame = 0
|
|
78
|
+
this.timer = null
|
|
79
|
+
this.onSigint = () => { this.finish(); this.out.write('\n'); process.exit(130) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
start() {
|
|
83
|
+
if (this.live) {
|
|
84
|
+
this.out.write('\x1b[?25l')
|
|
85
|
+
this.timer = setInterval(() => { this.frame++; this.render() }, 80)
|
|
86
|
+
process.on('SIGINT', this.onSigint)
|
|
87
|
+
}
|
|
88
|
+
this.render()
|
|
89
|
+
return this
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
set(i, patch) {
|
|
93
|
+
const step = this.steps[i]
|
|
94
|
+
Object.assign(step, patch)
|
|
95
|
+
if (!SETTLED.has(step.state)) step.printed = false
|
|
96
|
+
if (!this.live && SETTLED.has(step.state) && !step.printed) {
|
|
97
|
+
step.printed = true
|
|
98
|
+
const note = step.note ? ` ${D}${step.note}${R}` : ''
|
|
99
|
+
const detail = step.detail ? `\n ${D}${step.detail}${R}` : ''
|
|
100
|
+
this.out.write(` ${ICONS[step.state]} ${step.label}${note}${detail}\n`)
|
|
101
|
+
}
|
|
102
|
+
this.render()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
row(step) {
|
|
106
|
+
const icon = step.state === 'run' ? `${C}${SPIN[this.frame % SPIN.length]}${R}` : ICONS[step.state]
|
|
107
|
+
const label = step.state === 'pending' ? `${D}${step.label}${R}` : step.label
|
|
108
|
+
let right = step.state === 'run' && step.progress !== undefined ? bar(step.progress, this.frame) + ' ' : ''
|
|
109
|
+
if (step.note) right += `${D}${step.note}${R}`
|
|
110
|
+
return `${icon} ${fit(label, LABEL)} ${right}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
render() {
|
|
114
|
+
if (!this.live) return
|
|
115
|
+
const width = Math.max(44, Math.min(68, (this.out.columns || 80) - 6))
|
|
116
|
+
const lines = [`${D}┌─ ${R}${this.title}${D} ${'─'.repeat(Math.max(0, width - 1 - this.title.length))}┐${R}`]
|
|
117
|
+
for (const step of this.steps) {
|
|
118
|
+
lines.push(`${D}│${R} ${fit(this.row(step), width)} ${D}│${R}`)
|
|
119
|
+
if (step.detail) lines.push(`${D}│${R} ${fit(` ${D}${step.detail}`, width)} ${D}│${R}`)
|
|
120
|
+
}
|
|
121
|
+
lines.push(`${D}└${'─'.repeat(width + 2)}┘${R}`)
|
|
122
|
+
const up = this.drawn ? `\x1b[${this.drawn}A\r\x1b[J` : ''
|
|
123
|
+
this.out.write(up + lines.map(l => ` ${l}`).join('\n') + '\n')
|
|
124
|
+
this.drawn = lines.length
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Ask below the box, then fold the prompt lines back into the next redraw.
|
|
128
|
+
async confirm(lines, question, defaultYes = true) {
|
|
129
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null }
|
|
130
|
+
this.render()
|
|
131
|
+
const text = lines.map(l => ` ${l}`).join('\n') + '\n'
|
|
132
|
+
if (this.live) this.out.write('\x1b[?25h')
|
|
133
|
+
this.out.write('\n' + text)
|
|
134
|
+
const answer = await new Promise(resolve => {
|
|
135
|
+
const readline = require('readline')
|
|
136
|
+
const rl = readline.createInterface({ input: process.stdin, output: this.out })
|
|
137
|
+
rl.on('SIGINT', () => { rl.close(); process.kill(process.pid, 'SIGINT') })
|
|
138
|
+
rl.question(` ${question} ${D}${defaultYes ? '(Y/n)' : '(y/N)'}${R} `, a => { rl.close(); resolve(a.trim()) })
|
|
139
|
+
})
|
|
140
|
+
if (this.live) {
|
|
141
|
+
this.drawn += lines.length + 2
|
|
142
|
+
this.out.write('\x1b[?25l')
|
|
143
|
+
this.timer = setInterval(() => { this.frame++; this.render() }, 80)
|
|
144
|
+
this.render()
|
|
145
|
+
} else {
|
|
146
|
+
this.out.write('\n')
|
|
147
|
+
}
|
|
148
|
+
return answer ? /^y(es)?$/i.test(answer) : defaultYes
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
finish() {
|
|
152
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null }
|
|
153
|
+
this.render()
|
|
154
|
+
if (this.live) {
|
|
155
|
+
this.out.write('\x1b[?25h')
|
|
156
|
+
process.removeListener('SIGINT', this.onSigint)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { banner, formatBytes, formatDuration, Steps, colors: { R, D, G, Y, C } }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Launcher side of the Work map model: find an existing Clef-Flash runtime, or prepare one
|
|
2
|
+
// with prepare-work-map.py while reporting phases and downloaded bytes.
|
|
3
|
+
// The runtime lives outside node_modules, so `npm update` never removes or re-downloads it.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const os = require('os')
|
|
8
|
+
const { spawn } = require('child_process')
|
|
9
|
+
|
|
10
|
+
// mlx-community/clef-flash-8bit @ the revision pinned in prepare-work-map.py
|
|
11
|
+
const MODEL_BYTES = 10_690_311_092
|
|
12
|
+
// Weights + Python/MLX env (~0.6 GB) + uv cache, with headroom
|
|
13
|
+
const DISK_NEEDED = 12.5e9
|
|
14
|
+
const DEFAULT_DIR = path.join(os.homedir(), '.ctlsurf', 'clef-flash')
|
|
15
|
+
|
|
16
|
+
function defaultPaths() {
|
|
17
|
+
return { pythonPath: path.join(DEFAULT_DIR, 'env', 'bin', 'python'), modelPath: path.join(DEFAULT_DIR, 'model') }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Files of the pinned revision besides the indexed weight shards. An interrupted
|
|
21
|
+
// download can have clef_mlx.py (what ClefWorker checks) without the weights.
|
|
22
|
+
const REQUIRED_FILES = ['clef_mlx.py', 'config.json', 'tokenizer.json', 'joint_head.safetensors', 'model.safetensors.index.json']
|
|
23
|
+
|
|
24
|
+
function modelReady({ pythonPath, modelPath } = {}) {
|
|
25
|
+
if (!pythonPath || !modelPath || !fs.existsSync(pythonPath)) return false
|
|
26
|
+
try {
|
|
27
|
+
const index = JSON.parse(fs.readFileSync(path.join(modelPath, 'model.safetensors.index.json'), 'utf-8'))
|
|
28
|
+
return [...REQUIRED_FILES, ...Object.values(index.weight_map)].every(f => fs.existsSync(path.join(modelPath, f)))
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findModel(saved = {}) {
|
|
35
|
+
return [
|
|
36
|
+
{ pythonPath: saved.pythonPath, modelPath: saved.modelPath },
|
|
37
|
+
{ pythonPath: process.env.CTLSURF_CLEF_PYTHON, modelPath: process.env.CTLSURF_CLEF_MODEL },
|
|
38
|
+
defaultPaths(),
|
|
39
|
+
].find(modelReady) || null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findUv() {
|
|
43
|
+
const dirs = (process.env.PATH || '').split(path.delimiter)
|
|
44
|
+
.concat([path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.cargo', 'bin'), '/opt/homebrew/bin', '/usr/local/bin'])
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
const uv = path.join(dir, 'uv')
|
|
47
|
+
try { fs.accessSync(uv, fs.constants.X_OK); return uv } catch { /* keep looking */ }
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function freeBytes() {
|
|
53
|
+
let dir = DEFAULT_DIR
|
|
54
|
+
while (!fs.existsSync(dir)) dir = path.dirname(dir)
|
|
55
|
+
try {
|
|
56
|
+
const s = fs.statfsSync(dir)
|
|
57
|
+
return s.bavail * s.bsize
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Includes huggingface_hub's in-flight files under model/.cache, so the count grows during a download.
|
|
64
|
+
function dirBytes(dir) {
|
|
65
|
+
let total = 0
|
|
66
|
+
let entries
|
|
67
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return 0 }
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const p = path.join(dir, e.name)
|
|
70
|
+
if (e.isDirectory()) total += dirBytes(p)
|
|
71
|
+
else if (e.isFile()) { try { total += fs.statSync(p).size } catch { /* moved mid-scan */ } }
|
|
72
|
+
}
|
|
73
|
+
return total
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// onPhase('env' | 'deps' | 'model'), onBytes(downloadedBytes). Resolves with the ready paths.
|
|
77
|
+
function prepareModel({ uv, onPhase = () => {}, onBytes = () => {} }) {
|
|
78
|
+
fs.mkdirSync(DEFAULT_DIR, { recursive: true })
|
|
79
|
+
const logPath = path.join(DEFAULT_DIR, 'prepare.log')
|
|
80
|
+
const log = fs.createWriteStream(logPath)
|
|
81
|
+
const script = path.join(__dirname, 'prepare-work-map.py')
|
|
82
|
+
const { modelPath } = defaultPaths()
|
|
83
|
+
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
// uv supplies Python 3.12, so no system python3 (or Xcode CLT prompt) is involved.
|
|
86
|
+
const child = spawn(uv, ['run', '--no-project', '--python', '3.12', script, '--progress', '--directory', DEFAULT_DIR], {
|
|
87
|
+
cwd: DEFAULT_DIR,
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
env: {
|
|
90
|
+
...process.env,
|
|
91
|
+
PATH: `${path.dirname(uv)}${path.delimiter}${process.env.PATH || ''}`,
|
|
92
|
+
UV_CACHE_DIR: path.join(DEFAULT_DIR, 'uv'),
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
let poll = null
|
|
96
|
+
let buffer = ''
|
|
97
|
+
child.stdout.on('data', chunk => {
|
|
98
|
+
buffer += String(chunk)
|
|
99
|
+
let newline
|
|
100
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
101
|
+
const line = buffer.slice(0, newline)
|
|
102
|
+
buffer = buffer.slice(newline + 1)
|
|
103
|
+
const phase = /^::phase (\w+)/.exec(line)
|
|
104
|
+
if (!phase) { log.write(line + '\n'); continue }
|
|
105
|
+
onPhase(phase[1])
|
|
106
|
+
if (phase[1] === 'model' && !poll) {
|
|
107
|
+
onBytes(dirBytes(modelPath))
|
|
108
|
+
poll = setInterval(() => onBytes(dirBytes(modelPath)), 500)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
child.stderr.pipe(log, { end: false })
|
|
113
|
+
child.on('error', err => { clearInterval(poll); log.end(); reject(err) })
|
|
114
|
+
child.on('close', code => {
|
|
115
|
+
clearInterval(poll)
|
|
116
|
+
log.end(() => {
|
|
117
|
+
const paths = defaultPaths()
|
|
118
|
+
if (code === 0 && modelReady(paths)) { resolve(paths); return }
|
|
119
|
+
let tail = ''
|
|
120
|
+
try { tail = fs.readFileSync(logPath, 'utf-8').trim().split('\n').slice(-1)[0] } catch { /* no log */ }
|
|
121
|
+
reject(new Error(tail || `prepare-work-map.py exited with code ${code}`))
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { MODEL_BYTES, DISK_NEEDED, DEFAULT_DIR, modelReady, findModel, findUv, freeBytes, prepareModel }
|
package/src/main/index.ts
CHANGED
|
@@ -247,9 +247,17 @@ const orchestrator = new Orchestrator(
|
|
|
247
247
|
updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd)
|
|
248
248
|
},
|
|
249
249
|
},
|
|
250
|
-
{ appRoot:
|
|
250
|
+
{ appRoot: workMapRoot(), desktop: true },
|
|
251
251
|
)
|
|
252
252
|
|
|
253
|
+
// app.isPackaged can't tell the two apart: the npm launcher renames Electron's
|
|
254
|
+
// binary to "ctlsurf", which makes it true there too. Only an electron-builder
|
|
255
|
+
// build ships the Clef script under resources/ (extraResources).
|
|
256
|
+
function workMapRoot(): string {
|
|
257
|
+
const resources = process.resourcesPath
|
|
258
|
+
return fs.existsSync(path.join(resources, 'scripts', 'work-map-clef.py')) ? resources : app.getAppPath()
|
|
259
|
+
}
|
|
260
|
+
|
|
253
261
|
// ─── Window ───────────────────────────────────────
|
|
254
262
|
|
|
255
263
|
function createWindow(): void {
|