@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/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: electron.app.isPackaged ? process.resourcesPath : electron.app.getAppPath(), desktop: true }
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",
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": {
@@ -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
- console.log(capability.state === 'available'
13
- ? `[ctlsurf] Work map hardware detected: ${capability.gpuName}. GPU access is checked again by the desktop app.`
14
- : `[ctlsurf] ${capability.reason} Other app features remain available.`)
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: app.isPackaged ? process.resourcesPath : app.getAppPath(), desktop: true },
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 {