Restructure into a monorepo: models, VS Code extension, daemon, shared
- models/: GGUFs (Git LFS), manifest.json, model card, Apache-2.0 LICENSE + NOTICE - extensions/vscode/: the Local Autocomplete extension (v0.5.0, publisher harvmaster) - daemon/: acd completion daemon (bundled into the extension; also runs standalone) - shared/: prompt layout + symbol extraction shared with the training pipeline - scripts/: make-model-release.mjs, publish-extension-release.mjs (Gitea releases) - README with quick start, settings, architecture, build and release instructions; MIT license for code; CHANGELOG The default model manifest URL moves to models/manifest.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
import * as vscode from 'vscode'
|
||||
import { spawn } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as nodePath from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { MODE_DESCRIPTIONS, applySelectedSuggestion, repoAndPath, sliceDocument, statusText, type ContextMode } from './logic'
|
||||
import * as managed from './managed-core'
|
||||
|
||||
interface DaemonConfig {
|
||||
enabled: boolean
|
||||
model: string
|
||||
mode: ContextMode
|
||||
effectiveMode: ContextMode
|
||||
models: Record<string, { order: string; modes: ContextMode[]; defaultMode: ContextMode }>
|
||||
llama: { running: boolean; loading: boolean; error: string | null }
|
||||
}
|
||||
|
||||
let log: vscode.OutputChannel
|
||||
let status: vscode.StatusBarItem
|
||||
let daemonConfig: DaemonConfig | null = null
|
||||
let lastLatency: number | null = null
|
||||
|
||||
const settings = () => vscode.workspace.getConfiguration('localAutocomplete')
|
||||
const isManaged = () => settings().get<string>('daemon.mode', 'managed') === 'managed'
|
||||
const serverUrl = () => isManaged()
|
||||
? `http://127.0.0.1:${settings().get<number>('daemon.port', 7071)}`
|
||||
: settings().get<string>('serverUrl', 'http://127.0.0.1:7071').replace(/\/$/, '')
|
||||
|
||||
let extensionCtx: vscode.ExtensionContext
|
||||
let managedStarting: Promise<void> | null = null
|
||||
|
||||
function managedSettings(): managed.ManagedSettings {
|
||||
const c = settings()
|
||||
return {
|
||||
port: c.get<number>('daemon.port', 7071),
|
||||
manifestUrl: c.get<string>('modelManifestUrl', ''),
|
||||
model: c.get<string>('model', ''),
|
||||
modelPath: c.get<string>('modelPath', ''),
|
||||
modelProfile: c.get('modelProfile', { order: 'spm', modes: ['symbols', 'simple'], defaultMode: 'simple' }) as managed.ManagedSettings['modelProfile'],
|
||||
llamaServerPath: c.get<string>('llamaServerPath', ''),
|
||||
llamaServerArgs: c.get<string[]>('llamaServerArgs', []),
|
||||
recordingDir: c.get<string>('recordingDir', ''),
|
||||
idleShutdownMinutes: c.get<number>('daemon.idleShutdownMinutes', 30),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed mode: download (first run) and start the bundled daemon with the model
|
||||
* from the manifest, or reuse the one another window already started.
|
||||
*/
|
||||
function startManaged(): Promise<void> {
|
||||
managedStarting ??= (async () => {
|
||||
const storage = extensionCtx.globalStorageUri.fsPath
|
||||
const daemonDir = vscode.Uri.joinPath(extensionCtx.extensionUri, 'dist', 'daemon').fsPath
|
||||
const version = extensionCtx.extension.packageJSON.version as string
|
||||
try {
|
||||
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Local Autocomplete', cancellable: true }, async (progress, token) => {
|
||||
const abort = new AbortController()
|
||||
token.onCancellationRequested(() => abort.abort())
|
||||
let last = 0
|
||||
const report: managed.Progress = (message, fraction) => {
|
||||
const increment = fraction === undefined ? undefined : Math.max(0, fraction * 100 - last)
|
||||
if (fraction !== undefined) last = fraction * 100
|
||||
progress.report({ message, increment })
|
||||
log.appendLine(message)
|
||||
}
|
||||
const prepared = await managed.prepare(storage, daemonDir, version, managedSettings(), report, abort.signal)
|
||||
const how = await managed.ensureRunning(storage, daemonDir, prepared, managedSettings().port, report)
|
||||
log.appendLine(how === 'external'
|
||||
? `port ${managedSettings().port} is served by a daemon you started yourself; using it`
|
||||
: `managed daemon ${how} (${prepared.instanceTag}); log: ${storage}/acd.log`)
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
log.appendLine(`managed daemon: ${msg}`)
|
||||
const pick = await vscode.window.showErrorMessage(`Local Autocomplete: ${msg}`, 'Open Settings', 'Show Log')
|
||||
if (pick === 'Open Settings') void vscode.commands.executeCommand('workbench.action.openSettings', 'localAutocomplete')
|
||||
if (pick === 'Show Log') log.show()
|
||||
} finally {
|
||||
managedStarting = null
|
||||
await refreshConfig()
|
||||
}
|
||||
})()
|
||||
return managedStarting
|
||||
}
|
||||
|
||||
async function api<T>(method: string, route: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const res = await fetch(serverUrl() + route, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal })
|
||||
const json = await res.json() as T & { error?: string }
|
||||
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`)
|
||||
return json
|
||||
}
|
||||
|
||||
async function refreshConfig() {
|
||||
if (!settings().get<boolean>('enableCompletions', true)) {
|
||||
status.text = settings().get<boolean>('recordEdits', false) ? '$(record) AC: recording only' : '$(circle-slash) AC: completions off'
|
||||
status.tooltip = 'Local Autocomplete: completions disabled (localAutocomplete.enableCompletions)'
|
||||
return
|
||||
}
|
||||
try { daemonConfig = await api<DaemonConfig>('GET', '/v1/config') } catch { daemonConfig = null }
|
||||
status.text = statusText(daemonConfig, lastLatency)
|
||||
status.tooltip = daemonConfig
|
||||
? `Local Autocomplete\nmodel: ${daemonConfig.model}\nmode: ${daemonConfig.mode}${daemonConfig.effectiveMode !== daemonConfig.mode ? ` (running as ${daemonConfig.effectiveMode})` : ''}${daemonConfig.llama.error ? `\nerror: ${daemonConfig.llama.error}` : ''}\nclick for options`
|
||||
: `Local Autocomplete: daemon not reachable at ${serverUrl()}`
|
||||
}
|
||||
|
||||
async function ensureDaemon() {
|
||||
if (isManaged()) return startManaged()
|
||||
try { await api('GET', '/health'); return } catch { /* not running */ }
|
||||
const command = settings().get<string>('daemon.command', '')
|
||||
if (!settings().get<boolean>('daemon.autoStart', true) || !command) {
|
||||
log.appendLine(`daemon not reachable at ${serverUrl()} and no daemon.command configured`)
|
||||
return
|
||||
}
|
||||
log.appendLine(`starting daemon: ${command}`)
|
||||
const child = spawn(command, { shell: true, detached: true, stdio: 'ignore' })
|
||||
child.unref()
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try { await api('GET', '/health'); log.appendLine('daemon is up'); return } catch { /* still starting */ }
|
||||
}
|
||||
log.appendLine('daemon did not come up within 60 s')
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in edit recorder (localAutocomplete.recordEdits): records document
|
||||
* changes, cursor moves and file switches as local JSONL, either directly to
|
||||
* `recordingDir` or via the daemon. Real editing histories are the training
|
||||
* data for next-edit prediction. The first event per document (and every
|
||||
* 200th) carries a full snapshot so histories can be replayed. Every event is
|
||||
* tagged with the editor app, so e.g. sessions recorded in Cursor with its
|
||||
* own Tab model active can be excluded from training (Cursor ToS §1.5).
|
||||
*/
|
||||
class EditRecorder {
|
||||
private queue: unknown[] = []
|
||||
private counts = new Map<string, number>()
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private lastSelection = new Map<string, { offset: number; t: number }>()
|
||||
private readonly app = vscode.env.appName
|
||||
|
||||
private eligible(doc: vscode.TextDocument) {
|
||||
const cfg = settings()
|
||||
return cfg.get<boolean>('recordEdits', false) && doc.uri.scheme === 'file'
|
||||
&& cfg.get<string[]>('languages', []).includes(doc.languageId)
|
||||
&& !cfg.get<string[]>('excludeGlobs', []).some(pattern => vscode.languages.match({ pattern }, doc) > 0)
|
||||
}
|
||||
|
||||
onChange(e: vscode.TextDocumentChangeEvent) {
|
||||
if (!e.contentChanges.length || !this.eligible(e.document)) return
|
||||
const key = e.document.uri.toString()
|
||||
const n = this.counts.get(key) ?? 0
|
||||
this.counts.set(key, n + 1)
|
||||
const folder = vscode.workspace.getWorkspaceFolder(e.document.uri)
|
||||
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(e.document.uri, false), e.document.fileName)
|
||||
const editor = vscode.window.activeTextEditor
|
||||
this.queue.push({
|
||||
kind: 'edit', app: this.app, t: Date.now(), repo, path, languageId: e.document.languageId, version: e.document.version,
|
||||
reason: e.reason === vscode.TextDocumentChangeReason.Undo ? 'undo' : e.reason === vscode.TextDocumentChangeReason.Redo ? 'redo' : undefined,
|
||||
changes: e.contentChanges.map(c => ({ offset: c.rangeOffset, length: c.rangeLength, text: c.text })),
|
||||
cursor: editor?.document === e.document ? e.document.offsetAt(editor.selection.active) : undefined,
|
||||
// Snapshot of the document *after* this change.
|
||||
snapshot: n % 200 === 0 ? e.document.getText() : undefined,
|
||||
})
|
||||
this.timer ??= setTimeout(() => this.flush(), 2000)
|
||||
}
|
||||
|
||||
/** Cursor moves without edits (throttled): where the user goes next is half of next-edit prediction. */
|
||||
onSelection(e: vscode.TextEditorSelectionChangeEvent) {
|
||||
const doc = e.textEditor.document
|
||||
if (!this.eligible(doc) || e.kind === vscode.TextEditorSelectionChangeKind.Command) return
|
||||
const key = doc.uri.toString()
|
||||
const offset = doc.offsetAt(e.selections[0].active)
|
||||
const prev = this.lastSelection.get(key)
|
||||
const now = Date.now()
|
||||
if (prev && (prev.offset === offset || now - prev.t < 300)) return
|
||||
this.lastSelection.set(key, { offset, t: now })
|
||||
const folder = vscode.workspace.getWorkspaceFolder(doc.uri)
|
||||
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(doc.uri, false), doc.fileName)
|
||||
this.queue.push({ kind: 'cursor', app: this.app, t: now, repo, path, version: doc.version, cursor: offset, via: e.kind === vscode.TextEditorSelectionChangeKind.Mouse ? 'mouse' : 'keyboard' })
|
||||
this.timer ??= setTimeout(() => this.flush(), 2000)
|
||||
}
|
||||
|
||||
onActiveEditor(editor: vscode.TextEditor | undefined) {
|
||||
if (!editor || !this.eligible(editor.document)) return
|
||||
const doc = editor.document
|
||||
const folder = vscode.workspace.getWorkspaceFolder(doc.uri)
|
||||
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(doc.uri, false), doc.fileName)
|
||||
this.queue.push({ kind: 'focus', app: this.app, t: Date.now(), repo, path, version: doc.version, cursor: doc.offsetAt(editor.selection.active) })
|
||||
this.timer ??= setTimeout(() => this.flush(), 2000)
|
||||
}
|
||||
|
||||
forget(doc: vscode.TextDocument) { this.counts.delete(doc.uri.toString()); this.lastSelection.delete(doc.uri.toString()) }
|
||||
|
||||
flush() {
|
||||
if (this.timer) { clearTimeout(this.timer); this.timer = null }
|
||||
if (!this.queue.length) return
|
||||
const events = this.queue
|
||||
this.queue = []
|
||||
const dir = settings().get<string>('recordingDir', '').replace(/^~(?=$|\/)/, os.homedir())
|
||||
if (dir) {
|
||||
// Direct to disk: no daemon needed (recording-only setups, e.g. inside Cursor).
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.appendFileSync(nodePath.join(dir, `edits-${new Date().toISOString().slice(0, 10)}.jsonl`), events.map(e => JSON.stringify(e)).join('\n') + '\n')
|
||||
} catch (e) { log.appendLine(`edit recorder: ${(e as Error).message}`) }
|
||||
return
|
||||
}
|
||||
api('POST', '/v1/edits', { events }).catch(e => log.appendLine(`edit recorder: ${(e as Error).message}`))
|
||||
}
|
||||
}
|
||||
const recorder = new EditRecorder()
|
||||
|
||||
class Provider implements vscode.InlineCompletionItemProvider {
|
||||
async provideInlineCompletionItems(document: vscode.TextDocument, position: vscode.Position, context: vscode.InlineCompletionContext, token: vscode.CancellationToken) {
|
||||
const cfg = settings()
|
||||
if (!cfg.get<boolean>('enableCompletions', true)) return undefined
|
||||
if (daemonConfig && !daemonConfig.enabled) return undefined
|
||||
if (!cfg.get<string[]>('languages', []).includes(document.languageId)) return undefined
|
||||
if (cfg.get<string[]>('excludeGlobs', []).some(pattern => vscode.languages.match({ pattern }, document) > 0)) return undefined
|
||||
|
||||
let text = document.getText()
|
||||
let cursor = document.offsetAt(position)
|
||||
let replaceStart = position
|
||||
const selected = context.selectedCompletionInfo
|
||||
if (selected) {
|
||||
// Extend the highlighted IntelliSense item instead of contradicting it.
|
||||
const applied = applySelectedSuggestion(text, document.offsetAt(selected.range.start), cursor, selected.text)
|
||||
text = applied.text
|
||||
cursor = applied.cursorOffset
|
||||
replaceStart = selected.range.start
|
||||
}
|
||||
|
||||
const debounce = cfg.get<number>('debounceMs', 25)
|
||||
if (debounce > 0) await new Promise(r => setTimeout(r, debounce))
|
||||
if (token.isCancellationRequested) return undefined
|
||||
|
||||
const folder = vscode.workspace.getWorkspaceFolder(document.uri)
|
||||
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(document.uri, false), document.fileName)
|
||||
const slice = sliceDocument(text, cursor, cfg.get<number>('maxPrefixChars', 200000), cfg.get<number>('maxSuffixChars', 20000))
|
||||
const requestId = randomUUID()
|
||||
const abort = new AbortController()
|
||||
const sub = token.onCancellationRequested(() => abort.abort())
|
||||
try {
|
||||
const workspaceRoot = folder?.uri.scheme === 'file' ? folder.uri.fsPath : undefined
|
||||
const res = await api<{ text: string; cancelled?: boolean; replaceBefore?: number; filterText?: string; timings?: { totalMs: number } }>('POST', '/v1/fim', {
|
||||
requestId, repo, path, workspaceRoot, languageId: document.languageId, ...slice,
|
||||
}, abort.signal)
|
||||
if (res.timings) {
|
||||
lastLatency = res.timings.totalMs
|
||||
status.text = statusText(daemonConfig, lastLatency)
|
||||
}
|
||||
if (!res.text || res.cancelled || token.isCancellationRequested) return undefined
|
||||
let item: vscode.InlineCompletionItem
|
||||
if (res.replaceBefore) {
|
||||
// The daemon rewrote text before the cursor (e.g. "getU" -> "this.getUser(id)").
|
||||
if (selected) return undefined
|
||||
const start = position.translate(0, -res.replaceBefore)
|
||||
item = new vscode.InlineCompletionItem(res.text, new vscode.Range(start, position))
|
||||
item.filterText = res.filterText
|
||||
} else {
|
||||
const insertText = selected ? selected.text + res.text : res.text
|
||||
item = new vscode.InlineCompletionItem(insertText, new vscode.Range(replaceStart, position))
|
||||
}
|
||||
item.command = { command: 'localAutocomplete.accepted', title: 'accepted', arguments: [requestId, res.text] }
|
||||
return [item]
|
||||
} catch (e) {
|
||||
if (!abort.signal.aborted) log.appendLine(`completion failed: ${(e as Error).message}`)
|
||||
return undefined
|
||||
} finally {
|
||||
sub.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showMenu() {
|
||||
await refreshConfig()
|
||||
if (!daemonConfig) {
|
||||
const pick = await vscode.window.showQuickPick(['Start daemon', 'Show log'], { title: `Daemon not reachable at ${serverUrl()}` })
|
||||
if (pick === 'Start daemon') { await ensureDaemon(); await refreshConfig() }
|
||||
if (pick === 'Show log') log.show()
|
||||
return
|
||||
}
|
||||
const items = [
|
||||
{ label: '$(symbol-namespace) Context mode…', detail: `current: ${daemonConfig.mode}`, action: 'mode' },
|
||||
{ label: '$(server-process) Model…', detail: `current: ${daemonConfig.model}`, action: 'model' },
|
||||
{ label: daemonConfig.enabled ? '$(circle-slash) Turn off' : '$(check) Turn on', action: 'toggle' },
|
||||
...(isManaged() ? [
|
||||
{ label: '$(debug-restart) Restart daemon', action: 'restart' },
|
||||
{ label: '$(file) Open daemon log', action: 'daemonLog' },
|
||||
] : []),
|
||||
{ label: '$(output) Show log', action: 'log' },
|
||||
]
|
||||
const pick = await vscode.window.showQuickPick(items, { title: 'Local Autocomplete' })
|
||||
if (pick?.action === 'mode') await selectMode()
|
||||
if (pick?.action === 'model') await selectModel()
|
||||
if (pick?.action === 'toggle') await toggleEnabled()
|
||||
if (pick?.action === 'log') log.show()
|
||||
if (pick?.action === 'restart') {
|
||||
await api('POST', '/v1/shutdown').catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
await startManaged()
|
||||
}
|
||||
if (pick?.action === 'daemonLog') void vscode.window.showTextDocument(vscode.Uri.joinPath(extensionCtx.globalStorageUri, 'acd.log'))
|
||||
}
|
||||
|
||||
async function selectMode() {
|
||||
await refreshConfig()
|
||||
if (!daemonConfig) return
|
||||
const modes = daemonConfig.models[daemonConfig.model].modes
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
modes.map(m => ({ label: m, description: m === daemonConfig!.mode ? '(current)' : '', detail: MODE_DESCRIPTIONS[m] })),
|
||||
{ title: `Context mode for ${daemonConfig.model}` },
|
||||
)
|
||||
if (!pick) return
|
||||
await api('PUT', '/v1/config', { mode: pick.label })
|
||||
await refreshConfig()
|
||||
}
|
||||
|
||||
async function selectModel() {
|
||||
await refreshConfig()
|
||||
// Managed mode: offer every model in the manifest (downloading on demand), not just the ones on disk.
|
||||
const cached = vscode.Uri.joinPath(extensionCtx.globalStorageUri, 'manifest.json').fsPath
|
||||
if (isManaged() && !settings().get<string>('modelPath', '') && fs.existsSync(cached)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(cached, 'utf8')) as managed.Manifest
|
||||
const current = daemonConfig?.model
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
manifest.models.map(m => ({ label: m.name, description: m.name === current ? '(current)' : `${(m.size / 1e6).toFixed(0)} MB`, detail: m.description })),
|
||||
{ title: 'Model (downloads on first use)' },
|
||||
)
|
||||
if (!pick || pick.label === current) return
|
||||
await settings().update('model', pick.label, vscode.ConfigurationTarget.Global) // triggers re-prepare via onDidChangeConfiguration
|
||||
return
|
||||
}
|
||||
if (!daemonConfig) return
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
Object.entries(daemonConfig.models).map(([name, m]) => ({ label: name, description: name === daemonConfig!.model ? '(current)' : '', detail: `${m.order}, modes: ${m.modes.join(', ')}` })),
|
||||
{ title: 'Model (switching reloads llama-server)' },
|
||||
)
|
||||
if (!pick || pick.label === daemonConfig.model) return
|
||||
await api('PUT', '/v1/config', { model: pick.label })
|
||||
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Loading ${pick.label}…` }, async () => {
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await refreshConfig()
|
||||
if (daemonConfig && !daemonConfig.llama.loading) break
|
||||
}
|
||||
})
|
||||
if (daemonConfig?.llama.error) vscode.window.showErrorMessage(`Local Autocomplete: ${daemonConfig.llama.error}`)
|
||||
}
|
||||
|
||||
async function toggleEnabled() {
|
||||
await refreshConfig()
|
||||
if (!daemonConfig) return
|
||||
await api('PUT', '/v1/config', { enabled: !daemonConfig.enabled })
|
||||
await refreshConfig()
|
||||
}
|
||||
|
||||
export async function activate(ctx: vscode.ExtensionContext) {
|
||||
extensionCtx = ctx
|
||||
log = vscode.window.createOutputChannel('Local Autocomplete')
|
||||
status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100)
|
||||
status.command = 'localAutocomplete.showMenu'
|
||||
status.text = '$(loading~spin) AC'
|
||||
status.show()
|
||||
|
||||
ctx.subscriptions.push(
|
||||
log, status,
|
||||
vscode.languages.registerInlineCompletionItemProvider({ pattern: '**' }, new Provider()),
|
||||
vscode.commands.registerCommand('localAutocomplete.showMenu', showMenu),
|
||||
vscode.commands.registerCommand('localAutocomplete.selectMode', selectMode),
|
||||
vscode.commands.registerCommand('localAutocomplete.selectModel', selectModel),
|
||||
vscode.commands.registerCommand('localAutocomplete.toggleEnabled', toggleEnabled),
|
||||
vscode.commands.registerCommand('localAutocomplete.showLog', () => log.show()),
|
||||
vscode.commands.registerCommand('localAutocomplete.accepted', (requestId: string, text: string) => {
|
||||
api('POST', '/v1/feedback', { requestId, event: 'accepted', chars: text.length }).catch(() => {})
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration(e => {
|
||||
if (!e.affectsConfiguration('localAutocomplete')) return
|
||||
const managedKeys = ['daemon.mode', 'daemon.port', 'daemon.idleShutdownMinutes', 'modelManifestUrl', 'model', 'modelPath', 'modelProfile', 'llamaServerPath', 'llamaServerArgs', 'recordingDir']
|
||||
if (isManaged() && managedKeys.some(k => e.affectsConfiguration(`localAutocomplete.${k}`))) void startManaged()
|
||||
else void refreshConfig()
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument(e => recorder.onChange(e)),
|
||||
vscode.window.onDidChangeTextEditorSelection(e => recorder.onSelection(e)),
|
||||
vscode.window.onDidChangeActiveTextEditor(e => recorder.onActiveEditor(e)),
|
||||
vscode.workspace.onDidCloseTextDocument(doc => recorder.forget(doc)),
|
||||
{ dispose: () => recorder.flush() },
|
||||
vscode.window.onDidChangeWindowState(s => { if (s.focused) void refreshConfig() }),
|
||||
)
|
||||
// The toggle is shared with other editors and the acctl CLI, so pick up changes made elsewhere.
|
||||
const timer = setInterval(() => void refreshConfig(), 15000)
|
||||
ctx.subscriptions.push({ dispose: () => clearInterval(timer) })
|
||||
|
||||
const cfg = settings()
|
||||
if (cfg.get<boolean>('recordEdits', false) && /cursor/i.test(vscode.env.appName) && !ctx.globalState.get('cursorRecordingNoticeShown')) {
|
||||
void ctx.globalState.update('cursorRecordingNoticeShown', true)
|
||||
void vscode.window.showInformationMessage(
|
||||
'Local Autocomplete is recording edits inside Cursor. Cursor\'s terms (§1.5) prohibit using its Tab suggestions to train a competing model, so turn Cursor Tab off while recording if you want these recordings to be usable for training. Events are tagged with app="Cursor".')
|
||||
}
|
||||
if (!cfg.get<boolean>('enableCompletions', true)) {
|
||||
// Recording-only: no daemon needed when recordings go straight to disk.
|
||||
status.text = cfg.get<boolean>('recordEdits', false) ? '$(record) AC: recording only' : '$(circle-slash) AC: completions off'
|
||||
if (cfg.get<string>('recordingDir', '')) return
|
||||
}
|
||||
await ensureDaemon()
|
||||
await refreshConfig()
|
||||
}
|
||||
|
||||
export function deactivate() {}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure request-building logic (no vscode import), unit-tested in test/.
|
||||
*/
|
||||
|
||||
export interface DocumentSlice {
|
||||
prefix: string
|
||||
suffix: string
|
||||
/** Absolute line number where `prefix` starts (non-zero only when truncated). */
|
||||
prefixLineOffset: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the document at the cursor. Very large prefixes are cut at a line
|
||||
* whose number is a multiple of `lineChunk`, so the cut point (and the daemon's
|
||||
* KV cache) stays stable while typing.
|
||||
*/
|
||||
export function sliceDocument(text: string, offset: number, maxPrefixChars: number, maxSuffixChars: number, lineChunk = 256): DocumentSlice {
|
||||
let prefix = text.slice(0, offset)
|
||||
let prefixLineOffset = 0
|
||||
if (prefix.length > maxPrefixChars) {
|
||||
const lineStarts = [0]
|
||||
for (let i = prefix.indexOf('\n'); i !== -1; i = prefix.indexOf('\n', i + 1)) lineStarts.push(i + 1)
|
||||
let line = 0
|
||||
while (line + lineChunk < lineStarts.length && prefix.length - lineStarts[line] > maxPrefixChars) line += lineChunk
|
||||
prefix = prefix.slice(lineStarts[line])
|
||||
prefixLineOffset = line
|
||||
}
|
||||
let suffix = text.slice(offset, offset + maxSuffixChars)
|
||||
if (offset + maxSuffixChars < text.length) suffix = suffix.slice(0, suffix.lastIndexOf('\n') + 1)
|
||||
return { prefix, suffix, prefixLineOffset }
|
||||
}
|
||||
|
||||
/**
|
||||
* When VS Code's suggest widget is open, an inline completion must extend the
|
||||
* selected suggestion. We pretend the selected text was already typed: the
|
||||
* prefix ends at the replace range's start plus the selected text.
|
||||
*/
|
||||
export function applySelectedSuggestion(text: string, rangeStartOffset: number, cursorOffset: number, selectedText: string): { text: string; cursorOffset: number } {
|
||||
return {
|
||||
text: text.slice(0, rangeStartOffset) + selectedText + text.slice(cursorOffset),
|
||||
cursorOffset: rangeStartOffset + selectedText.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** Repo name and '/'-separated relative path, as the model saw them in training. */
|
||||
export function repoAndPath(workspaceFolderName: string | undefined, relativePath: string, fileName: string): { repo: string; path: string } {
|
||||
if (!workspaceFolderName) return { repo: 'untitled', path: fileName.split(/[\\/]/).pop() || 'untitled' }
|
||||
return { repo: workspaceFolderName, path: relativePath.replace(/\\/g, '/') }
|
||||
}
|
||||
|
||||
export type ContextMode = 'symbols' | 'simple' | 'raw'
|
||||
|
||||
export const MODE_DESCRIPTIONS: Record<ContextMode, string> = {
|
||||
symbols: 'repo header + available symbols from the TypeScript language service',
|
||||
simple: 'repo name + file path header only (fast, no language service)',
|
||||
raw: 'no header: plain suffix-first FIM (for models trained without it)',
|
||||
}
|
||||
|
||||
export function statusText(cfg: { enabled: boolean; mode: ContextMode; effectiveMode?: ContextMode; model: string; llama?: { loading: boolean; running: boolean } } | null, lastMs: number | null): string {
|
||||
if (!cfg) return '$(debug-disconnect) AC: no daemon'
|
||||
if (!cfg.enabled) return '$(circle-slash) AC: off'
|
||||
if (cfg.llama?.loading) return '$(loading~spin) AC: loading'
|
||||
const mode = cfg.effectiveMode && cfg.effectiveMode !== cfg.mode ? `${cfg.mode}→${cfg.effectiveMode}` : cfg.mode
|
||||
return `$(sparkle) AC: ${mode}${lastMs !== null ? ` · ${Math.round(lastMs)} ms` : ''}`
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Managed daemon: everything needed to run acd without a separate install,
|
||||
* kept free of the vscode API so it can be tested with plain Node.
|
||||
*
|
||||
* - manifest.json (self-hosted) lists models and the pinned llama.cpp build;
|
||||
* relative URLs resolve against the manifest URL, so all files can sit in one folder
|
||||
* - downloads resume (.part + Range), are SHA-256 verified, and are guarded by
|
||||
* a lock file so several windows don't download the same file at once
|
||||
* - the bundled daemon runs on the editor's own Node (ELECTRON_RUN_AS_NODE)
|
||||
*/
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import * as crypto from 'node:crypto'
|
||||
import * as http from 'node:http'
|
||||
import * as https from 'node:https'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
|
||||
export interface ModelEntry {
|
||||
name: string
|
||||
file: string
|
||||
url: string
|
||||
sha256: string
|
||||
size: number
|
||||
order: 'spm' | 'psm'
|
||||
modes: ('symbols' | 'simple' | 'raw')[]
|
||||
defaultMode: 'symbols' | 'simple' | 'raw'
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
schema: 1
|
||||
default: string
|
||||
models: ModelEntry[]
|
||||
llamaCpp: { build: string; assets: Record<string, { url: string; sha256: string }> }
|
||||
}
|
||||
|
||||
/** Fallback when the manifest has no llama.cpp section (or can't be fetched): pinned upstream release. */
|
||||
export const DEFAULT_LLAMA_CPP: Manifest['llamaCpp'] = {
|
||||
build: 'b11368',
|
||||
assets: {
|
||||
'linux-x64': { url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11368/llama-b11368-bin-ubuntu-x64.tar.gz', sha256: '1c267259a8db2a28f2b212aef4df67b197aa184377a222369135f6fafdbfe235' },
|
||||
'linux-arm64': { url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11368/llama-b11368-bin-ubuntu-arm64.tar.gz', sha256: '98f78c5d1f6ec04f0d64bc028bd9345d5665c29fd18c1a7c8c3ed0ba54ceb94e' },
|
||||
'darwin-arm64': { url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11368/llama-b11368-bin-macos-arm64.tar.gz', sha256: '2e98cd5d0afb2cc00e62db33f1bd9048d4942ceef9bab9affb01832d6ad3735c' },
|
||||
'darwin-x64': { url: 'https://github.com/ggml-org/llama.cpp/releases/download/b11368/llama-b11368-bin-macos-x64.tar.gz', sha256: 'b46659767fbf39f8a8ff3cb78f7e8722e6b5bc92f3eb448bbe322463d83675b7' },
|
||||
},
|
||||
}
|
||||
|
||||
export type Progress = (message: string, fraction?: number) => void
|
||||
|
||||
export const platformKey = () => `${process.platform}-${process.arch}`
|
||||
|
||||
// ---------- HTTP
|
||||
|
||||
function get(url: string, headers: Record<string, string> = {}, redirects = 5): Promise<http.IncomingMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith('https:') ? https : http
|
||||
const req = lib.get(url, { headers: { 'user-agent': 'local-autocomplete', ...headers } }, res => {
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
if (redirects <= 0) return reject(new Error(`too many redirects for ${url}`))
|
||||
return resolve(get(new URL(res.headers.location, url).toString(), headers, redirects - 1))
|
||||
}
|
||||
resolve(res)
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.setTimeout(60_000, () => req.destroy(new Error(`timeout fetching ${url}`)))
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchManifest(manifestUrl: string, cachePath: string): Promise<Manifest> {
|
||||
try {
|
||||
const res = await get(manifestUrl)
|
||||
if (res.statusCode !== 200) { res.resume(); throw new Error(`HTTP ${res.statusCode}`) }
|
||||
const chunks: Buffer[] = []
|
||||
for await (const c of res) chunks.push(c as Buffer)
|
||||
const manifest = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Manifest
|
||||
// Resolve relative URLs against the manifest location.
|
||||
for (const m of manifest.models) m.url = new URL(m.url, manifestUrl).toString()
|
||||
for (const a of Object.values(manifest.llamaCpp?.assets ?? {})) a.url = new URL(a.url, manifestUrl).toString()
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true })
|
||||
fs.writeFileSync(cachePath, JSON.stringify(manifest, null, 2))
|
||||
return manifest
|
||||
} catch (e) {
|
||||
// Offline startup: use the last manifest we saw.
|
||||
if (fs.existsSync(cachePath)) return JSON.parse(fs.readFileSync(cachePath, 'utf8')) as Manifest
|
||||
throw new Error(`cannot fetch model manifest ${manifestUrl}: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(file: string): Promise<string> {
|
||||
const hash = crypto.createHash('sha256')
|
||||
for await (const chunk of fs.createReadStream(file)) hash.update(chunk as Buffer)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
/** Cross-window lock: O_EXCL lock file, considered stale after 10 minutes without progress. */
|
||||
async function withLock<T>(lockPath: string, fn: () => Promise<T>, progress: Progress): Promise<T> {
|
||||
for (;;) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(lockPath), { recursive: true })
|
||||
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' })
|
||||
break
|
||||
} catch {
|
||||
const age = Date.now() - (fs.statSync(lockPath, { throwIfNoEntry: false })?.mtimeMs ?? 0)
|
||||
if (age > 10 * 60_000) { fs.rmSync(lockPath, { force: true }); continue }
|
||||
progress('waiting for another window to finish downloading…')
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
}
|
||||
try { return await fn() } finally { fs.rmSync(lockPath, { force: true }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads url to dest (resuming a previous .part), verifies SHA-256, and
|
||||
* records the verified hash so later startups don't re-hash 500 MB.
|
||||
*/
|
||||
export async function ensureFile(url: string, dest: string, sha256: string, label: string, progress: Progress, signal?: AbortSignal): Promise<string> {
|
||||
const marker = `${dest}.verified`
|
||||
if (fs.existsSync(dest) && fs.existsSync(marker) && fs.readFileSync(marker, 'utf8').trim() === sha256) return dest
|
||||
return withLock(`${dest}.lock`, async () => {
|
||||
if (fs.existsSync(dest) && (await sha256File(dest)) === sha256) { fs.writeFileSync(marker, sha256); return dest }
|
||||
const part = `${dest}.part`
|
||||
let have = fs.statSync(part, { throwIfNoEntry: false })?.size ?? 0
|
||||
const res = await get(url, have ? { range: `bytes=${have}-` } : {})
|
||||
if (res.statusCode === 200) have = 0 // server ignored the range: start over
|
||||
else if (res.statusCode !== 206) { res.resume(); throw new Error(`downloading ${label}: HTTP ${res.statusCode} from ${url}`) }
|
||||
const total = have + Number(res.headers['content-length'] ?? 0)
|
||||
const out = fs.createWriteStream(part, { flags: have ? 'a' : 'w' })
|
||||
let received = have
|
||||
let lastReport = 0
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => { res.destroy(new Error('cancelled')) })
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length
|
||||
if (Date.now() - lastReport > 250) {
|
||||
lastReport = Date.now()
|
||||
progress(`downloading ${label}: ${(received / 1e6).toFixed(0)} / ${total ? (total / 1e6).toFixed(0) : '?'} MB`, total ? received / total : undefined)
|
||||
}
|
||||
})
|
||||
res.on('error', reject)
|
||||
out.on('error', reject)
|
||||
out.on('finish', () => resolve())
|
||||
res.pipe(out)
|
||||
})
|
||||
progress(`verifying ${label}…`)
|
||||
const actual = await sha256File(part)
|
||||
if (actual !== sha256) {
|
||||
fs.rmSync(part, { force: true })
|
||||
throw new Error(`${label}: checksum mismatch (expected ${sha256.slice(0, 12)}…, got ${actual.slice(0, 12)}…); the partial download was deleted`)
|
||||
}
|
||||
fs.renameSync(part, dest)
|
||||
fs.writeFileSync(marker, sha256)
|
||||
return dest
|
||||
}, progress)
|
||||
}
|
||||
|
||||
/** Downloads and unpacks the pinned llama.cpp build for this platform; returns the llama-server path. */
|
||||
export async function ensureLlamaServer(storage: string, llama: Manifest['llamaCpp'], progress: Progress, signal?: AbortSignal): Promise<string> {
|
||||
const asset = llama.assets[platformKey()]
|
||||
if (!asset) throw new Error(`no llama.cpp build for ${platformKey()}; set localAutocomplete.llamaServerPath`)
|
||||
const dir = path.join(storage, 'llama.cpp', llama.build)
|
||||
const find = (d: string): string | null => {
|
||||
if (!fs.existsSync(d)) return null
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name)
|
||||
if (e.isFile() && e.name === 'llama-server') return p
|
||||
if (e.isDirectory()) { const f = find(p); if (f) return f }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const existing = find(dir)
|
||||
if (existing) return existing
|
||||
const archive = await ensureFile(asset.url, path.join(storage, 'llama.cpp', path.basename(new URL(asset.url).pathname)), asset.sha256, `llama.cpp ${llama.build}`, progress, signal)
|
||||
progress('unpacking llama.cpp…')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
execFileSync('tar', ['-xzf', archive, '-C', dir])
|
||||
const server = find(dir)
|
||||
if (!server) throw new Error(`llama-server not found in ${archive}`)
|
||||
fs.chmodSync(server, 0o755)
|
||||
return server
|
||||
}
|
||||
|
||||
export interface ManagedSettings {
|
||||
port: number
|
||||
manifestUrl: string
|
||||
model: string
|
||||
modelPath: string
|
||||
modelProfile: Pick<ModelEntry, 'order' | 'modes' | 'defaultMode'>
|
||||
llamaServerPath: string
|
||||
llamaServerArgs: string[]
|
||||
recordingDir: string
|
||||
idleShutdownMinutes: number
|
||||
}
|
||||
|
||||
export interface Prepared { configPath: string; instanceTag: string }
|
||||
|
||||
/** Resolves model + llama-server (downloading as needed) and writes the daemon config. */
|
||||
export async function prepare(storage: string, daemonDir: string, extensionVersion: string, s: ManagedSettings, progress: Progress, signal?: AbortSignal): Promise<Prepared> {
|
||||
const models: Record<string, { path: string } & Pick<ModelEntry, 'order' | 'modes' | 'defaultMode'>> = {}
|
||||
let defaultModel: string
|
||||
let llamaCpp = DEFAULT_LLAMA_CPP
|
||||
|
||||
let manifest: Manifest | null = null
|
||||
if (s.manifestUrl) {
|
||||
progress('fetching model manifest…')
|
||||
manifest = await fetchManifest(s.manifestUrl, path.join(storage, 'manifest.json'))
|
||||
if (manifest.llamaCpp?.assets) llamaCpp = manifest.llamaCpp
|
||||
}
|
||||
if (s.modelPath) {
|
||||
const p = s.modelPath.replace(/^~(?=$|\/)/, os.homedir())
|
||||
if (!fs.existsSync(p)) throw new Error(`localAutocomplete.modelPath does not exist: ${p}`)
|
||||
defaultModel = 'custom'
|
||||
models.custom = { path: p, ...s.modelProfile }
|
||||
} else {
|
||||
if (!manifest) throw new Error('set localAutocomplete.modelManifestUrl (your model server) or localAutocomplete.modelPath (a local .gguf)')
|
||||
const entry = manifest.models.find(m => m.name === (s.model || manifest!.default))
|
||||
if (!entry) throw new Error(`model "${s.model || manifest.default}" is not in the manifest (available: ${manifest.models.map(m => m.name).join(', ')})`)
|
||||
const file = await ensureFile(entry.url, path.join(storage, 'models', entry.file), entry.sha256, `model ${entry.name}`, progress, signal)
|
||||
defaultModel = entry.name
|
||||
models[entry.name] = { path: file, order: entry.order, modes: entry.modes, defaultMode: entry.defaultMode }
|
||||
// Other models already on disk stay switchable from the menu.
|
||||
for (const m of manifest.models) {
|
||||
const f = path.join(storage, 'models', m.file)
|
||||
if (m.name !== entry.name && fs.existsSync(`${f}.verified`)) models[m.name] = { path: f, order: m.order, modes: m.modes, defaultMode: m.defaultMode }
|
||||
}
|
||||
}
|
||||
|
||||
const binary = s.llamaServerPath ? s.llamaServerPath.replace(/^~(?=$|\/)/, os.homedir()) : await ensureLlamaServer(storage, llamaCpp, progress, signal)
|
||||
const gpuArgs = process.platform === 'darwin' ? ['-ngl', '99'] : ['-t', String(Math.max(1, Math.floor(os.cpus().length / 2)))]
|
||||
const config = {
|
||||
host: '127.0.0.1',
|
||||
port: s.port,
|
||||
tokenizer: { json: path.join(daemonDir, 'assets/qwen-tokenizer.json'), config: path.join(daemonDir, 'assets/qwen-tokenizer_config.json') },
|
||||
llamaServer: { binary, port: s.port + 1, ctx: 8192, args: s.llamaServerArgs.length ? s.llamaServerArgs : gpuArgs },
|
||||
models,
|
||||
defaultModel,
|
||||
prompt: { prefixTokens: 1536, suffixTokens: 512, prefixChunkLines: 32, maxLines: 8, nPredictSingleLine: 48, nPredictMultiLine: 128 },
|
||||
symbols: { budget: 2048, waitMs: 150, members: false },
|
||||
thisRepair: { enabled: true, waitMs: 100 },
|
||||
telemetry: { enabled: false, dir: path.join(storage, 'telemetry') },
|
||||
recording: { dir: s.recordingDir ? s.recordingDir.replace(/^~(?=$|\/)/, os.homedir()) : path.join(storage, 'recordings') },
|
||||
stateFile: path.join(storage, 'acd.state.json'),
|
||||
idleShutdownMinutes: s.idleShutdownMinutes,
|
||||
instanceTag: '',
|
||||
}
|
||||
// The tag changes whenever the extension or the effective config changes, so stale daemons get replaced.
|
||||
const { instanceTag: _, ...hashed } = config
|
||||
config.instanceTag = `managed:${extensionVersion}:${crypto.createHash('sha1').update(JSON.stringify(hashed)).digest('hex').slice(0, 10)}`
|
||||
const configPath = path.join(storage, 'acd.config.json')
|
||||
fs.mkdirSync(storage, { recursive: true })
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
|
||||
return { configPath, instanceTag: config.instanceTag }
|
||||
}
|
||||
|
||||
async function health(port: number): Promise<{ ok: boolean; instanceTag: string | null } | null> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) })
|
||||
return await res.json() as { ok: boolean; instanceTag: string | null }
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure a daemon with our instance tag is serving on `port`.
|
||||
* - ours (same tag): reuse it (shared by every window)
|
||||
* - managed but outdated (tag starts with "managed:"): ask it to shut down, then start ours
|
||||
* - not managed (you started it yourself): leave it alone and use it
|
||||
*/
|
||||
export async function ensureRunning(storage: string, daemonDir: string, prepared: Prepared, port: number, progress: Progress): Promise<'reused' | 'started' | 'external'> {
|
||||
const current = await health(port)
|
||||
if (current?.instanceTag === prepared.instanceTag) return 'reused'
|
||||
if (current && !current.instanceTag?.startsWith('managed:')) return 'external'
|
||||
if (current) {
|
||||
progress('restarting the completion daemon (updated)…')
|
||||
await fetch(`http://127.0.0.1:${port}/v1/shutdown`, { method: 'POST' }).catch(() => {})
|
||||
for (let i = 0; i < 40 && (await health(port)); i++) await new Promise(r => setTimeout(r, 250))
|
||||
}
|
||||
progress('starting the completion daemon…')
|
||||
const logPath = path.join(storage, 'acd.log')
|
||||
const log = fs.openSync(logPath, 'a')
|
||||
const child = spawn(process.execPath, [path.join(daemonDir, 'acd.mjs'), '--config', prepared.configPath], {
|
||||
detached: true,
|
||||
stdio: ['ignore', log, log],
|
||||
// In desktop VS Code process.execPath is the Electron app; this makes it behave as plain Node.
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
})
|
||||
child.unref()
|
||||
for (let i = 0; i < 240; i++) {
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
const h = await health(port)
|
||||
if (h?.ok && h.instanceTag === prepared.instanceTag) return 'started'
|
||||
if (h && h.instanceTag !== prepared.instanceTag && !h.instanceTag?.startsWith('managed:')) return 'external' // someone else won the port
|
||||
if (child.exitCode !== null && !h) throw new Error(`daemon exited (code ${child.exitCode}); see ${logPath}`)
|
||||
}
|
||||
throw new Error(`daemon did not become ready within 60 s; see ${logPath}`)
|
||||
}
|
||||
Reference in New Issue
Block a user