#!/usr/bin/env node import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const VERSION = '0.1.0-mvp'; const GLOBAL_CLI = parseGlobalCli(process.argv.slice(2)); function parseGlobalCli(argv) { const options = {}; const args = []; for (let i = 0; i < argv.length; i += 1) { const item = argv[i]; if (item === '--profile') { options.profile = cleanArg(argv[i + 1]); i += 1; } else if (item.startsWith('--profile=')) { options.profile = cleanArg(item.slice('--profile='.length)); } else if (item === '--profile-json-b64') { options.profileJsonB64 = cleanArg(argv[i + 1]); i += 1; } else if (item.startsWith('--profile-json-b64=')) { options.profileJsonB64 = cleanArg(item.slice('--profile-json-b64='.length)); } else if (item === '--pod-id') { options.podId = cleanArg(argv[i + 1]); i += 1; } else if (item.startsWith('--pod-id=')) { options.podId = cleanArg(item.slice('--pod-id='.length)); } else if (item === '--workspace') { options.workspace = cleanArg(argv[i + 1]); i += 1; } else if (item.startsWith('--workspace=')) { options.workspace = cleanArg(item.slice('--workspace='.length)); } else { args.push(cleanArg(item)); } } return { options, args }; } function cleanArg(value) { const text = String(value ?? ''); if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) return text.slice(1, -1); return text; } function decodeBase64Utf8(value, label = 'base64 value') { if (typeof value !== 'string' || !value) throw new Error(`${label} requires a non-empty base64 value`); return Buffer.from(value, 'base64').toString('utf8'); } function optionValue(options, ...keys) { for (const key of keys) { if (options[key] !== undefined) return options[key]; } return undefined; } function optionList(options, ...keys) { const values = []; for (const key of keys) { const value = options[key]; if (value === undefined || value === false) continue; values.push(...(Array.isArray(value) ? value : [value])); } return values.map((value) => String(value)).filter(Boolean); } function booleanOption(options, ...keys) { return keys.some((key) => options[key] === true); } function optionString(options, ...keys) { const value = optionValue(options, ...keys); if (value === undefined || value === null || value === false || value === true) return undefined; return String(Array.isArray(value) ? value[0] : value); } function normalizeTextEncoding(value) { const normalized = String(value || 'utf8').trim().toLowerCase().replace(/[-_]/gu, ''); if (normalized === 'utf8') return 'utf8'; if (normalized === 'latin1' || normalized === 'binary') return 'latin1'; throw new Error(`unsupported text encoding: ${value}. Use utf8 or latin1`); } function selectedPodId() { const explicitPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || ''; if (explicitPodId) return explicitPodId; const requestedProfile = GLOBAL_CLI.options.profile || process.env.DEVICE_POD_PROFILE || ''; if (requestedProfile) { const root = workspaceRoot(); const file = path.isAbsolute(requestedProfile) ? requestedProfile : path.resolve(root, requestedProfile); if (fs.existsSync(file)) { const profile = readJson(file); const profilePodId = profile.devicePodId || profile.podId; if (profilePodId) return profilePodId; } const basenamePodId = path.basename(file, path.extname(file)); if (basenamePodId) return basenamePodId; } throw new Error('device-pod profile requires --pod-id, DEVICE_POD_ID, or an explicit profile containing podId/devicePodId; no default pod is assumed'); } function workspaceRoot() { return path.resolve(GLOBAL_CLI.options.workspace || process.env.DEVICE_POD_WORKSPACE || process.cwd()); } function devicePodDir(root = workspaceRoot()) { return path.join(root, '.device-pod'); } function stateRoot(root = workspaceRoot()) { return path.join(devicePodDir(root), '.state', 'device-host-cli'); } function nowIso() { return new Date().toISOString(); } function ok(action, data = {}) { console.log(JSON.stringify({ ok: true, action, generatedAt: nowIso(), data }, null, 2)); } function emit(action, data = {}, okValue = true, exitCode = 0) { console.log(JSON.stringify({ ok: okValue, action, generatedAt: nowIso(), data }, null, 2)); process.exitCode = exitCode; } function fail(action, error, details = {}, exitCode = 1) { const message = error instanceof Error ? error.message : String(error); const stack = error instanceof Error && error.stack ? error.stack.split('\n').slice(0, 8) : undefined; console.log(JSON.stringify({ ok: false, action, generatedAt: nowIso(), error: message, stack, details, ...hintForHostFailure(action, message) }, null, 2)); process.exitCode = exitCode; } function hintForHostFailure(action, message) { if (action === 'device-host-cli' && /patch|hunk|\*\*\* Begin Patch|\*\*\* End Patch|Update File|Add File|Delete File/iu.test(String(message || ''))) { return { patchHint: patchFailureHint(message) }; } return {}; } function patchFailureHint(message = '') { const text = String(message || ''); const next = ['Re-read the target file with `hwpod :workspace:/... cat ` or `rg` before retrying.']; if (/raw file content|Codex patch envelope|patch must start with|patch must end with|one line|stdin heredoc/iu.test(text)) { next.unshift('For `apply-patch --add-file `, send raw file content through a real multi-line stdin heredoc: `hwpod :workspace:/ apply-patch --add-file User/new.c <<\'EOF\'` then file lines, then `EOF` on its own line. Do not include `*** Begin Patch` with --add-file.'); } if (/end with \*\*\* End Patch/iu.test(text)) { next.unshift('Add a final `*** End Patch` line. The marker is required and must be exactly capitalized.'); } else if (/start with \*\*\* Begin Patch/iu.test(text)) { next.unshift('Start the patch with an exact `*** Begin Patch` line.'); } else if (/hunk did not match|must start with @@|invalid update line prefix|ellipsis|cannot use ellipsis/iu.test(text)) { next.unshift('Retry with a smaller ordinary hunk using exact current context lines; do not use ellipsis or line-number-only hunks.'); } else if (/unsupported patch header|capitalization|New File/iu.test(text)) { next.unshift('Use exactly `*** Update File:`, `*** Add File:`, or `*** Delete File:` headers.'); } else if (/add file line must start with \+/iu.test(text)) { next.unshift('For direct `*** Add File:` patches, prefix every content line with `+`; for raw new-file content prefer `apply-patch --add-file < file`.'); } next.push('Do not switch to `--replace-file` or `workspace put` for an existing source file unless a smaller hunk cannot safely express the edit.'); return { standardForm: [ '*** Begin Patch', '*** Update File: path/to/file.c', '@@', ' exact unchanged context line', '-old line', '+new line', '*** End Patch' ], next }; } function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); } function writeJson(file, value) { ensureDir(path.dirname(file)); fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function profilePath(root = workspaceRoot(), podId = selectedPodId()) { return path.join(devicePodDir(root), `${podId}.json`); } function loadProfile() { const root = workspaceRoot(); if (GLOBAL_CLI.options.profileJsonB64) return loadInlineProfile(root); const requestedProfile = GLOBAL_CLI.options.profile || process.env.DEVICE_POD_PROFILE || ''; const file = requestedProfile ? (path.isAbsolute(requestedProfile) ? requestedProfile : path.resolve(root, requestedProfile)) : profilePath(root); if (!fs.existsSync(file)) throw new Error(`device-pod profile not found: ${file}`); const profile = readJson(file); const actualPodId = profile.devicePodId || profile.podId; const requestedPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || ''; if (requestedPodId && actualPodId && actualPodId !== requestedPodId) throw new Error(`device-pod profile mismatch: requested ${requestedPodId}, profile ${actualPodId}`); profile.__profilePath = file; profile.__workspaceRoot = path.resolve(profile.workspaceRoot || root); return profile; } function loadInlineProfile(root) { const profile = JSON.parse(decodeBase64Utf8(GLOBAL_CLI.options.profileJsonB64, '--profile-json-b64')); const actualPodId = profile.devicePodId || profile.podId; const requestedPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || ''; if (requestedPodId && actualPodId && actualPodId !== requestedPodId) throw new Error(`device-pod profile mismatch: requested ${requestedPodId}, profile ${actualPodId}`); const runtimePath = path.join(devicePodDir(root), '.runtime', `${actualPodId || requestedPodId || 'device-pod'}.json`); writeJson(runtimePath, profile); profile.__profilePath = runtimePath; profile.__workspaceRoot = path.resolve(profile.workspaceRoot || root); return profile; } function resolveWorkspacePath(profile, rel = '.') { const root = profile.__workspaceRoot; const raw = String(rel || '.').replace(/^\/+/, ''); if (raw.includes('\0')) throw new Error('path contains NUL'); const resolved = path.resolve(root, raw); const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new Error(`path escapes workspace: ${rel}`); return resolved; } function projectPath(profile) { return resolveWorkspacePath(profile, profile.projectWorkspace?.projectPath); } function uvoptxPath(projectFile) { return projectFile.replace(/\.uvprojx$/i, '.uvoptx'); } function targetName(profile) { return profile.projectWorkspace?.targetName || ''; } function hexPath(profile) { return resolveWorkspacePath(profile, profile.projectWorkspace?.hexPath); } function shortTail(text, max = 12000) { const value = String(text || ''); return value.length <= max ? value : value.slice(value.length - max); } function runCapture(file, args = [], options = {}) { const timeoutMs = options.timeoutMs ?? 15000; const startedAt = Date.now(); return new Promise((resolve) => { const child = spawn(file, args, { cwd: options.cwd || workspaceRoot(), env: { ...process.env, ...(options.env || {}) }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; let timedOut = false; const timer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, timeoutMs); child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); child.on('error', (error) => { clearTimeout(timer); resolve({ exitCode: 127, stdout, stderr: `${stderr}${error.message}\n`, timedOut, elapsedMs: Date.now() - startedAt }); }); child.on('close', (code) => { clearTimeout(timer); resolve({ exitCode: timedOut ? 124 : (code ?? 1), stdout, stderr, timedOut, elapsedMs: Date.now() - startedAt }); }); }); } async function runPowerShell(script, timeoutMs = 15000) { const prefix = [ '$ErrorActionPreference = \'Stop\';', '[Console]::InputEncoding = [System.Text.UTF8Encoding]::new();', '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new();', '$OutputEncoding = [System.Text.UTF8Encoding]::new();', ].join(' '); return await runCapture('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `${prefix} ${script}`], { timeoutMs }); } function psQuote(value) { return `'${String(value).replace(/'/g, "''")}'`; } function psArray(values) { return `@(${values.map(psQuote).join(', ')})`; } function parseArgs(args) { const out = { _: [] }; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (/^-[A-Za-z]$/u.test(arg)) { const key = arg.slice(1); const next = args[i + 1]; if (['g', 't'].includes(key) && next !== undefined && !next.startsWith('-')) { setOption(out, key, cleanArg(next)); i += 1; } else { setOption(out, key, true); } continue; } if (!arg.startsWith('--')) { out._.push(cleanArg(arg)); continue; } const eq = arg.indexOf('='); if (eq >= 0) { setOption(out, arg.slice(2, eq), cleanArg(arg.slice(eq + 1))); continue; } const key = arg.slice(2); const next = args[i + 1]; if (next !== undefined && !next.startsWith('--')) { setOption(out, key, cleanArg(next)); i += 1; } else { setOption(out, key, true); } } return out; } function setOption(out, key, value) { if (out[key] === undefined) out[key] = value; else if (Array.isArray(out[key])) out[key].push(value); else out[key] = [out[key], value]; } function splitOptionalWorkspacePath(args, fallback = '.') { const first = args[0]; if (first === undefined || String(first).startsWith('-')) return { rel: fallback, optionArgs: args }; return { rel: first, optionArgs: args.slice(1) }; } function findFile(candidates) { for (const candidate of candidates.filter(Boolean)) { if (fs.existsSync(candidate)) return candidate; } return null; } async function commandExists(command) { const where = process.platform === 'win32' ? 'where' : 'which'; const result = await runCapture(where, [command], { timeoutMs: 5000 }); return result.exitCode === 0 ? result.stdout.trim().split(/\r?\n/u)[0] : null; } async function toolInfo(profile) { const uv4 = findFile([ profile.debugInterface?.uv4Path, process.env.UV4_EXE, 'D:\\Keil_v5\\UV4\\UV4.exe', 'C:\\Keil_v5\\UV4\\UV4.exe', 'C:\\Keil\\UV4\\UV4.exe', ]); const pyocd = findFile([profile.debugInterface?.pyocdPath]) || await commandExists('pyocd'); const pyocdVersion = pyocd ? await runCapture(pyocd, ['--version'], { timeoutMs: 5000 }) : null; return { node: { path: process.execPath, version: process.version }, powershell: await commandExists('powershell.exe'), uv4: { path: uv4, found: Boolean(uv4) }, pyocd: { path: pyocd, found: Boolean(pyocd), version: pyocdVersion ? pyocdVersion.stdout.trim() || pyocdVersion.stderr.trim() : null, }, }; } async function health() { const profile = loadProfile(); const tools = await toolInfo(profile); ok('health', { version: VERSION, host: os.hostname(), platform: process.platform, workspaceRoot: profile.__workspaceRoot, profilePath: profile.__profilePath, profileExists: fs.existsSync(profile.__profilePath), projectExists: fs.existsSync(projectPath(profile)), hexExists: fs.existsSync(hexPath(profile)), stateRoot: stateRoot(profile.__workspaceRoot), runtimeDependencies: ['node built-ins', 'Windows PowerShell/.NET', 'Keil UV4.exe', 'pyOCD executable'], skillRuntimeDependencies: [], tools, }); } function listWorkspace(rel) { return listWorkspaceWithOptions(rel, {}); } function parsePositiveInt(value, fallback, label, min = 0, max = 10) { if (value === undefined || value === false || value === true || value === '') return fallback; const number = Number(value); if (!Number.isInteger(number) || number < min || number > max) throw new Error(`invalid ${label}: ${value}. Use ${min}..${max}`); return number; } function listWorkspaceWithOptions(rel, options = {}) { const profile = loadProfile(); const dir = resolveWorkspacePath(profile, rel || '.'); const depth = parsePositiveInt(optionValue(options, 'depth'), booleanOption(options, 'tree', 'recursive') ? 2 : 0, 'ls depth', 0, 6); const limit = parsePositiveInt(optionValue(options, 'limit'), 300, 'ls limit', 1, 2000); const entries = readWorkspaceEntries(dir, { root: profile.__workspaceRoot }); const tree = depth > 0 || booleanOption(options, 'tree', 'recursive') ? buildWorkspaceTree(dir, { root: profile.__workspaceRoot, maxDepth: depth, limit }) : undefined; const workspaceRelativePath = path.relative(profile.__workspaceRoot, dir) || '.'; ok('workspace.ls', { path: workspaceRelativePath, workspaceRoot: profile.__workspaceRoot, selectorPath: normalizeSlashPath(workspaceRelativePath), entries, tree, guidance: workspaceNavigationGuidance(selectedPodId(), workspaceRelativePath) }); } function readWorkspaceEntries(dir, context) { return fs.readdirSync(dir, { withFileTypes: true }).map((entry) => { const full = path.join(dir, entry.name); const stat = fs.statSync(full); return { name: entry.name, type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other', path: normalizeSlashPath(path.relative(context.root, full)), bytes: stat.size, mtime: stat.mtime.toISOString() }; }).sort(sortDirFirstByName); } function sortDirFirstByName(a, b) { const rank = (entry) => entry.type === 'dir' ? 0 : entry.type === 'file' ? 1 : 2; return rank(a) - rank(b) || a.name.localeCompare(b.name); } function buildWorkspaceTree(dir, context) { const out = []; const maxDepth = context.maxDepth; const limit = context.limit; let truncated = false; function visit(current, depth) { if (out.length >= limit) { truncated = true; return; } for (const entry of readWorkspaceEntries(current, context)) { if (out.length >= limit) { truncated = true; return; } const full = path.join(current, entry.name); out.push({ ...entry, depth }); if (entry.type === 'dir' && depth < maxDepth) visit(full, depth + 1); } } visit(dir, 0); return { root: normalizeSlashPath(path.relative(context.root, dir)) || '.', maxDepth, limit, truncated, entries: out }; } function workspaceNavigationGuidance(podId, workspaceRelativePath) { const base = workspaceRelativePath && workspaceRelativePath !== '.' ? `/${normalizeSlashPath(workspaceRelativePath)}` : '/'; return { startOfTask: `Run hwpod bootsharp --pod-id ${podId} at the start of a device-pod task and after context compaction/resume.`, explore: [ `hwpod ${podId}:workspace:${base} ls --tree --depth 2`, `hwpod ${podId}:workspace:${base} rg [path]`, `hwpod ${podId}:workspace:${base} cat --offset 0 --limit 4096` ], edit: [ `hwpod ${podId}:workspace:${base} apply-patch <<'PATCH'`, `hwpod ${podId}:workspace:${base} apply-patch --add-file <<'EOF'` ], note: 'Prefer subdir selectors such as :workspace:/projects/01_baseline so patch paths stay short and relative to the selected base.' }; } function readAgentsMd(profile, baseDir, options = {}) { const limit = parsePositiveInt(optionValue(options, 'agents-limit', 'agentsLimit'), 200000, 'AGENTS.md limit', 0, 1000000); const candidates = [path.join(baseDir, 'AGENTS.md'), path.join(profile.__workspaceRoot, 'AGENTS.md')]; const found = candidates.find((candidate, index) => fs.existsSync(candidate) && (index === 0 || candidate !== candidates[0])); if (!found) return { found: false, path: null, content: '', bytes: 0, truncated: false }; const buffer = fs.readFileSync(found); const truncated = limit > 0 && buffer.length > limit; const content = buffer.subarray(0, limit || buffer.length).toString('utf8'); return { found: true, path: normalizeSlashPath(path.relative(profile.__workspaceRoot, found)) || 'AGENTS.md', bytes: buffer.length, truncated, content }; } function bootsharpWorkspace(rel = '.', options = {}) { const profile = loadProfile(); const dir = resolveWorkspacePath(profile, rel || '.'); const depth = parsePositiveInt(optionValue(options, 'depth'), 2, 'bootsharp depth', 0, 6); const limit = parsePositiveInt(optionValue(options, 'limit'), 500, 'bootsharp tree limit', 1, 3000); const workspaceRelativePath = path.relative(profile.__workspaceRoot, dir) || '.'; ok('workspace.bootsharp', { podId: selectedPodId(), path: workspaceRelativePath, workspaceRoot: profile.__workspaceRoot, tree: buildWorkspaceTree(dir, { root: profile.__workspaceRoot, maxDepth: depth, limit }), agentsMd: readAgentsMd(profile, dir, options), guidance: { requiredWhen: [ 'Run this command before the first edit in a device-pod task.', 'Run this command again after context compaction, session resume, or when the active device-pod/workspace is unclear.' ], pathDiscipline: 'Use ls/rg/cat from the selected workspace base before guessing paths. For nested projects, prefer a selector like :workspace:/projects/01_baseline.', patchDiscipline: 'Use apply-patch through a real multi-line stdin heredoc. Do not create temporary patch files solely to pass content to hwpod. Use apply-patch --add-file for new files; it creates missing parent directories and expects raw file content, not *** Begin Patch envelopes. Do not use put/replace-file for existing source unless ordinary hunks cannot safely express the edit.', examples: workspaceNavigationGuidance(selectedPodId(), workspaceRelativePath) } }); } function catWorkspace(rel, options = {}) { const profile = loadProfile(); const file = resolveWorkspacePath(profile, rel); const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8'); const offset = Number(options.offset || 0); const limit = Number(options.limit || 40000); if (!Number.isFinite(offset) || offset < 0) throw new Error(`invalid cat offset: ${options.offset}`); if (!Number.isFinite(limit) || limit < 0) throw new Error(`invalid cat limit: ${options.limit}`); const buffer = fs.readFileSync(file); const chunk = buffer.subarray(offset, Math.min(buffer.length, offset + limit)); const content = chunk.toString(encoding); ok('workspace.cat', { path: path.relative(profile.__workspaceRoot, file), bytes: buffer.length, offset, returnedBytes: chunk.length, truncated: offset + chunk.length < buffer.length, encoding, base64: Boolean(options.base64), content: options.base64 ? chunk.toString('base64') : content, }); } function workspacePut(rel, options = {}) { if (!rel || String(rel).startsWith('-')) throw new Error('workspace put requires a target path'); const profile = loadProfile(); const file = resolveWorkspacePath(profile, rel); const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8'); const parent = path.dirname(file); if ((options['create-only'] === true || options.createOnly === true) && fs.existsSync(file)) throw new Error(`put target already exists: ${rel}`); if ((options['update-only'] === true || options.updateOnly === true) && !fs.existsSync(file)) throw new Error(`put target does not exist: ${rel}`); const content = decodePutBuffer(options, encoding); if (!fs.existsSync(parent)) { if (options['create-dirs'] === true || options.createDirs === true) ensureDir(parent); else throw new Error(`put parent directory not found for ${rel}: ${path.relative(profile.__workspaceRoot, parent) || '.'}. Check the workspace-relative path or pass --create-dirs when creating a new directory intentionally.`); } fs.writeFileSync(file, content); ok('workspace.put', { path: path.relative(profile.__workspaceRoot, file), bytes: content.length, sha256: createHash('sha256').update(content).digest('hex'), encoding, createOnly: Boolean(options['create-only'] || options.createOnly), updateOnly: Boolean(options['update-only'] || options.updateOnly), createDirs: Boolean(options['create-dirs'] || options.createDirs), }); } function workspaceRmdir(rel) { if (!rel || String(rel).startsWith('-')) throw new Error('workspace rmdir requires an empty directory path'); const profile = loadProfile(); const dir = resolveWorkspacePath(profile, rel); if (!fs.existsSync(dir)) throw new Error(`rmdir target not found: ${rel}`); if (!fs.statSync(dir).isDirectory()) throw new Error(`rmdir target is not a directory: ${rel}`); const entries = fs.readdirSync(dir); if (entries.length > 0) throw new Error(`rmdir target is not empty: ${rel}`); fs.rmdirSync(dir); ok('workspace.rmdir', { path: path.relative(profile.__workspaceRoot, dir) || '.', removed: true }); } function workspaceRm(rel, options = {}) { if (!rel || String(rel).startsWith('-')) throw new Error('workspace rm requires a file path'); const profile = loadProfile(); const file = resolveWorkspacePath(profile, rel); const missingOk = Boolean(options['missing-ok'] || options.missingOk); if (!fs.existsSync(file)) { if (missingOk) { ok('workspace.rm', { path: normalizeWorkspaceRel(rel), removed: false, reason: 'missing' }); return; } throw new Error(`rm target not found: ${rel}`); } if (!fs.statSync(file).isFile()) throw new Error(`rm target is not a file: ${rel}. Use workspace rmdir for empty directories.`); const content = fs.readFileSync(file); fs.rmSync(file, { force: true }); ok('workspace.rm', { path: path.relative(profile.__workspaceRoot, file), removed: true, bytes: content.length, sha256: createHash('sha256').update(content).digest('hex') }); } function walkFiles(root, rel, results, limit = 20000) { if (results.length >= limit) return true; const dir = path.join(root, rel); let truncated = false; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.state') continue; const childRel = path.join(rel, entry.name); if (entry.isDirectory()) truncated = walkFiles(root, childRel, results, limit) || truncated; else if (entry.isFile()) results.push(childRel); if (results.length >= limit) return true; } return truncated; } function rgWorkspace(pattern, rel, options = {}) { const profile = loadProfile(); const root = resolveWorkspacePath(profile, rel || '.'); const files = []; const scanLimit = parsePositiveInt(optionValue(options, 'limit'), 20000, 'rg limit', 1, 100000); let filesTruncated = false; const stat = fs.statSync(root); if (stat.isFile()) files.push(path.relative(profile.__workspaceRoot, root)); else { const nested = []; const rootRel = path.relative(profile.__workspaceRoot, root) || '.'; filesTruncated = walkFiles(root, '.', nested, scanLimit); files.push(...nested.map((fileRel) => rootRel === '.' ? fileRel : path.join(rootRel, fileRel))); } const re = new RegExp(pattern, 'i'); const matches = []; const fileMatches = []; const filesWithMatches = Boolean(options.l || options.filesWithMatches || options['files-with-matches'] || options._?.includes('-l')); const nameOnly = Boolean(filesWithMatches || options.nameOnly || options['name-only'] || options.names || options._?.includes('--name-only')); const globs = normalizeList(options.g).concat(normalizeList(options.glob), normalizeList(options.iglob)); const types = normalizeList(options.t).concat(normalizeList(options.type)); for (const fileRel of files) { if (!rgFileAllowed(fileRel, { globs, types })) continue; if (nameOnly) { if (re.test(fileRel.replace(/\\/gu, '/'))) fileMatches.push(fileRel); continue; } if (matches.length >= 100) break; const full = resolveWorkspacePath(profile, fileRel); const buffer = fs.readFileSync(full); if (buffer.includes(0)) continue; const lines = buffer.toString('utf8').split(/\r?\n/u); for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!re.test(line)) continue; if (filesWithMatches) { fileMatches.push(fileRel); break; } if (matches.length < 100) matches.push({ path: fileRel, line: index + 1, text: line.slice(0, 500) }); } } ok('workspace.rg', { pattern, root: path.relative(profile.__workspaceRoot, root) || '.', count: nameOnly ? fileMatches.length : matches.length, files: nameOnly ? fileMatches : undefined, matches: nameOnly ? undefined : matches, options: { globs, types, filesWithMatches, nameOnly, scanLimit, filesScanned: files.length, filesTruncated } }); } function resolveRgWorkspaceRequest(rest) { const parsed = parseArgs(rest); const encodedPattern = parsed['pattern-b64'] || parsed.patternB64; if (encodedPattern !== undefined) { return { pattern: decodeBase64Utf8(encodedPattern, '--pattern-b64'), rel: optionString(parsed, 'path') || parsed._[0] || '.', options: parsed, }; } if (parsed.pattern !== undefined) { return { pattern: String(Array.isArray(parsed.pattern) ? parsed.pattern[0] : parsed.pattern), rel: optionString(parsed, 'path') || parsed._[0] || '.', options: parsed, }; } return { pattern: rest[0] || '', rel: rest[1] || '.', options: parseArgs(rest.slice(2)), }; } function normalizeList(value) { if (value === undefined || value === false) return []; return (Array.isArray(value) ? value : [value]).map((item) => String(item)).filter(Boolean); } function rgFileAllowed(fileRel, options = {}) { const rel = fileRel.replace(/\\/gu, '/'); if (options.types?.length) { const ext = path.extname(rel).toLowerCase(); const allowed = new Set(); for (const type of options.types) { if (type === 'c') ['.c', '.h'].forEach((item) => allowed.add(item)); else if (type === 'cc') ['.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx'].forEach((item) => allowed.add(item)); } if (allowed.size && !allowed.has(ext)) return false; } if (!options.globs?.length) return true; return options.globs.some((glob) => matchSimpleGlob(rel, glob)); } function matchSimpleGlob(fileRel, glob) { const raw = String(glob || '').replace(/\\/gu, '/'); const target = raw.includes('/') ? fileRel : path.basename(fileRel); return new RegExp(`^${globToRegExp(raw)}$`, 'iu').test(target); } function globToRegExp(glob) { let out = ''; for (let i = 0; i < glob.length; i += 1) { const char = glob[i]; if (char === '*' && glob[i + 1] === '*') { out += '.*'; i += 1; } else if (char === '*') out += '[^/]*'; else if (char === '?') out += '[^/]'; else out += char.replace(/[.+^${}()|[\]\\]/gu, '\\$&'); } return out; } function workspaceApplyPatch(baseRel, options = {}) { const profile = loadProfile(); const patchText = decodePatchText(profile, options); const encoding = normalizeTextEncoding(options.encoding || options.charset || 'utf8'); const basePath = resolveWorkspacePath(profile, baseRel || '.'); const baseDir = fs.existsSync(basePath) && fs.statSync(basePath).isFile() ? path.dirname(basePath) : basePath; const operations = parseApplyPatch(patchText); const changed = []; for (const operation of operations) { const target = resolvePatchPath(profile, baseDir, operation.path); if (operation.kind === 'add') { if (fs.existsSync(target)) throw new Error(`add target already exists: ${operation.path}`); const parent = path.dirname(target); if (!fs.existsSync(parent)) { ensureDir(parent); } fs.writeFileSync(target, operation.content, encoding); changed.push(path.relative(profile.__workspaceRoot, target)); continue; } if (operation.kind === 'delete') { if (!fs.existsSync(target)) throw new Error(`delete target not found: ${operation.path}`); fs.rmSync(target, { force: true }); changed.push(path.relative(profile.__workspaceRoot, target)); continue; } if (operation.kind === 'update') { if (!fs.existsSync(target)) throw new Error(`update target not found: ${operation.path}`); const original = fs.readFileSync(target, encoding); const updated = applyUpdateChunks(original, operation.chunks, operation.path); const destination = operation.movePath ? resolvePatchPath(profile, baseDir, operation.movePath) : target; const parent = path.dirname(destination); if (!fs.existsSync(parent)) { if (options['create-dirs'] === true || options.createDirs === true) ensureDir(parent); else throw new Error(`update destination parent directory not found for ${operation.movePath || operation.path}: ${path.relative(profile.__workspaceRoot, parent) || '.'}. Check the patch path or pass --create-dirs when creating a new directory intentionally.`); } fs.writeFileSync(destination, updated, encoding); if (destination !== target) fs.rmSync(target, { force: true }); changed.push(destination === target ? path.relative(profile.__workspaceRoot, target) : `${path.relative(profile.__workspaceRoot, target)} -> ${path.relative(profile.__workspaceRoot, destination)}`); continue; } } ok('workspace.apply-patch', { base: path.relative(profile.__workspaceRoot, baseDir) || '.', changed, operationCount: operations.length, encoding }); } function decodePatchText(profile, options) { const patchFile = optionValue(options, 'patch-file', 'patchFile'); if (typeof patchFile === 'string') { const file = resolveWorkspacePath(profile, patchFile); return fs.readFileSync(file, 'utf8'); } if (typeof options.patch === 'string') return options.patch; if (typeof options.patchB64 === 'string') return Buffer.from(options.patchB64, 'base64').toString('utf8'); if (typeof options['patch-b64'] === 'string') return Buffer.from(options['patch-b64'], 'base64').toString('utf8'); throw new Error('workspace apply-patch requires --patch-file, --patch-b64, or --patch'); } function decodePutBuffer(options, encoding) { const contentB64 = optionValue(options, 'content-b64', 'contentB64'); if (typeof contentB64 === 'string') return Buffer.from(contentB64, 'base64'); const textB64 = optionValue(options, 'text-b64', 'textB64'); if (typeof textB64 === 'string') return Buffer.from(decodeBase64Utf8(textB64, '--text-b64'), encoding); const text = optionValue(options, 'text'); if (typeof text === 'string') return Buffer.from(text, encoding); throw new Error('workspace put requires --content-b64, --text-b64, or --text'); } function resolvePatchPath(profile, baseDir, patchPath) { const raw = String(patchPath || '').replace(/^\/+/, ''); if (!raw || raw.includes('\0')) throw new Error(`invalid patch path: ${patchPath}`); const root = profile.__workspaceRoot; const baseRel = normalizeSlashPath(path.relative(root, baseDir)); const patchRel = normalizeSlashPath(raw); const resolved = baseRel && (patchRel === baseRel || patchRel.startsWith(`${baseRel}/`)) ? path.resolve(root, raw) : path.resolve(baseDir, raw); const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; if (resolved !== root && !resolved.startsWith(rootWithSep)) throw new Error(`patch path escapes workspace: ${patchPath}`); return resolved; } function normalizeSlashPath(value) { return String(value || '').replace(/\\/gu, '/').replace(/^\/+|\/+$/gu, '').replace(/\/+/gu, '/'); } function parseApplyPatch(patchText) { const lines = String(patchText).replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); if (lines[lines.length - 1] === '') lines.pop(); if (lines[0]?.startsWith('--- ') || lines.some((line) => line.startsWith('+++ '))) { throw new Error('workspace apply-patch expects Codex patch format, not unified diff. Start with *** Begin Patch and use *** Update File:/*** Add File:/*** Delete File: hunks. Use workspace put only for intentional whole-file replacement.'); } if (lines[0] !== '*** Begin Patch') throw new Error('patch must start with *** Begin Patch'); if (lines[lines.length - 1] !== '*** End Patch') throw new Error('patch must end with *** End Patch'); const operations = []; let index = 1; while (index < lines.length - 1) { const header = lines[index++]; if (/^\*\*\*\s+(add|delete|update)\s+file:/iu.test(header) && !/^\*\*\*\s+(Add|Delete|Update) File: /u.test(header)) { throw new Error(`unsupported patch header capitalization: ${header}. Use exactly *** Update File:, *** Add File:, or *** Delete File:.`); } if (header.startsWith('*** New File: ')) { throw new Error('unsupported patch header: *** New File. Use *** Add File: for patch-created files, or workspace put only for intentional whole-file replacement.'); } if (header.startsWith('*** Add File: ')) { const filePath = header.slice('*** Add File: '.length).trim(); const content = []; while (index < lines.length - 1 && !lines[index].startsWith('*** ')) { const line = lines[index++]; if (line.startsWith('@@')) continue; if (!line.startsWith('+')) { throw new Error(`add file line must start with + for ${filePath}. For whole-file creation, prefer workspace apply-patch --add-file ${filePath} < file; if writing a Codex patch directly, omit bare hunk text and prefix every content line with +.`); } content.push(line.slice(1)); } operations.push({ kind: 'add', path: filePath, content: `${content.join('\n')}\n` }); continue; } if (header.startsWith('*** Delete File: ')) { operations.push({ kind: 'delete', path: header.slice('*** Delete File: '.length).trim() }); continue; } if (header.startsWith('*** Update File: ')) { const filePath = header.slice('*** Update File: '.length).trim(); const chunks = []; let movePath = null; while (index < lines.length - 1 && !lines[index].startsWith('*** Add File: ') && !lines[index].startsWith('*** Delete File: ') && !lines[index].startsWith('*** Update File: ')) { const line = lines[index++]; if (line.startsWith('*** Move to: ')) { movePath = line.slice('*** Move to: '.length).trim(); continue; } if (!line.startsWith('@@')) throw new Error(`update hunk for ${filePath} must start with @@`); if (line.includes('...')) throw new Error(`update hunk for ${filePath} cannot use ellipsis in ${line}. Include exact context lines from workspace cat/rg, then retry workspace apply-patch.`); const changes = []; while (index < lines.length - 1 && !lines[index].startsWith('@@') && !lines[index].startsWith('*** ')) { const change = lines[index++]; const prefix = change[0]; if (prefix !== ' ' && prefix !== '+' && prefix !== '-') throw new Error(`invalid update line prefix in ${filePath}: ${change}`); changes.push({ prefix, text: change.slice(1) }); } chunks.push(changes); } operations.push({ kind: 'update', path: filePath, movePath, chunks }); continue; } throw new Error(`unsupported patch header: ${header}`); } return operations; } function applyUpdateChunks(original, chunks, filePath) { const hadFinalNewline = original.endsWith('\n'); const current = original.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); if (current[current.length - 1] === '') current.pop(); let cursor = 0; for (const chunk of chunks) { const oldLines = chunk.filter((line) => line.prefix !== '+').map((line) => line.text); const newLines = chunk.filter((line) => line.prefix !== '-').map((line) => line.text); const at = oldLines.length === 0 ? cursor : findSubsequence(current, oldLines, cursor); if (at < 0) throw new Error(`patch hunk did not match ${filePath}. Re-read the target with workspace cat/rg and retry with exact context; do not use abbreviated context or ellipsis. Use workspace put only for intentional whole-file replacement.`); current.splice(at, oldLines.length, ...newLines); cursor = at + newLines.length; } const output = current.join('\n'); return hadFinalNewline || chunks.length > 0 ? `${output}\n` : output; } function findSubsequence(haystack, needle, start) { if (needle.length === 0) return start; for (let index = Math.max(0, start); index <= haystack.length - needle.length; index += 1) { let matched = true; for (let offset = 0; offset < needle.length; offset += 1) { if (haystack[index + offset] !== needle[offset]) { matched = false; break; } } if (matched) return index; } return -1; } function jobDir(profile) { const dir = path.join(stateRoot(profile.__workspaceRoot), 'jobs'); ensureDir(dir); return dir; } function jobFile(profile, jobId) { return path.join(jobDir(profile), `${jobId}.json`); } function latestJobId(profile, kindPrefix) { const dir = jobDir(profile); const jobs = fs.readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => { const file = path.join(dir, name); try { return { file, data: readJson(file) }; } catch { return null; } }).filter(Boolean).filter((job) => !kindPrefix || String(job.data.kind || '').startsWith(kindPrefix)); jobs.sort((a, b) => String(b.data.createdAt || '').localeCompare(String(a.data.createdAt || ''))); return jobs[0]?.data?.jobId || null; } function startJob(kind, payload) { const profile = loadProfile(); const id = `${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${kind}-${randomBytes(3).toString('hex')}`; const file = jobFile(profile, id); const job = { jobId: id, kind, status: 'queued', createdAt: nowIso(), updatedAt: nowIso(), profilePath: profile.__profilePath, workspaceRoot: profile.__workspaceRoot, payload }; writeJson(file, job); const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '__job', id], { cwd: profile.__workspaceRoot, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, DEVICE_POD_PROFILE: profile.__profilePath, DEVICE_POD_WORKSPACE: profile.__workspaceRoot }, }); child.unref(); ok(`${kind}.start`, { jobId: id, statusFile: file }); } function readJobStatus(kindPrefix, requestedId) { const profile = loadProfile(); const id = requestedId || latestJobId(profile, kindPrefix); if (!id) throw new Error(`no job found for ${kindPrefix}`); const job = readJson(jobFile(profile, id)); if (job.logFile && fs.existsSync(job.logFile)) job.logTail = shortTail(fs.readFileSync(job.logFile, 'utf8')); emit(`${kindPrefix}.status`, job, job.status !== 'failed', job.status === 'failed' ? 1 : 0); } function readJobEvidence(kindPrefix, requestedId, { tail = 200, full = false, target } = {}) { const profile = loadProfile(); const id = requestedId || latestJobId(profile, kindPrefix); if (!id) { return ok(`${kindPrefix}.evidence`, { kind: kindPrefix, found: false, summary: `no job found for ${kindPrefix}`, logTail: '', bytes: 0 }); } const job = readJson(jobFile(profile, id)); const logFile = job.logFile; const found = Boolean(logFile && fs.existsSync(logFile)); const fullText = found ? fs.readFileSync(logFile, 'utf8') : ''; const lines = fullText.split(/\r?\n/u); const slice = full ? lines : lines.slice(Math.max(0, lines.length - tail)); const text = slice.join('\n'); const summary = job.result?.buildSummary || job.result?.failureSummary || (job.status === 'completed' ? 'job completed' : `job ${job.status || 'unknown'}`); return ok(`${kindPrefix}.evidence`, { kind: kindPrefix, jobId: id, status: job.status, found, logFile: logFile ? path.relative(profile.__workspaceRoot, logFile) : null, bytes: Buffer.byteLength(fullText, 'utf8'), truncated: !full && lines.length > tail, tail: full ? lines.length : tail, logTail: text, summary, target: target || job.payload?.target || null, updatedAt: job.updatedAt || null }); } async function runJob(jobId) { const profile = loadProfile(); const file = jobFile(profile, jobId); const job = readJson(file); const update = (patch) => writeJson(file, { ...readJson(file), ...patch, updatedAt: nowIso() }); update({ status: 'running', startedAt: nowIso(), pid: process.pid }); try { let result; if (job.kind === 'keil-build') result = await runKeilBuild(profile, job.payload || {}); else if (job.kind === 'keil-download') result = await runKeilDownload(profile, job.payload || {}); else throw new Error(`unsupported job kind: ${job.kind}`); update({ status: result.success ? 'completed' : 'failed', finishedAt: nowIso(), result, logFile: result.logFile || null }); process.exitCode = result.success ? 0 : 1; } catch (error) { update({ status: 'failed', finishedAt: nowIso(), error: error instanceof Error ? error.message : String(error) }); process.exitCode = 1; } } function safeTarget(value) { return String(value || 'target').replace(/[^A-Za-z0-9_-]/g, '_'); } function keilLogHasErrors(logText) { return keilLogSummary(logText).hasErrors; } function keilLogSummary(logText) { const text = String(logText || ''); const summaryMatches = [...text.matchAll(/(\d+)\s+Error\(s\)\s*,\s*(\d+)\s+Warning\(s\)/giu)]; if (summaryMatches.length) { const last = summaryMatches[summaryMatches.length - 1]; const errors = Number(last[1]); const warnings = Number(last[2]); return { hasSummary: true, errors, warnings, hasErrors: errors > 0 }; } const errorLines = text.split(/\r?\n/u).filter((line) => { const trimmed = line.trim(); if (!trimmed) return false; if (/\bwarning\b/iu.test(trimmed)) return false; return /\b(fatal error|error:|failed)\b/iu.test(trimmed); }); return { hasSummary: false, errors: errorLines.length, warnings: null, hasErrors: errorLines.length > 0, errorLines: errorLines.slice(0, 8) }; } function xmlPosition(text, index) { const prefix = text.slice(0, Math.max(0, index)); const lines = prefix.split(/\n/u); return { line: lines.length, column: lines[lines.length - 1].length + 1 }; } function validateXmlFile(file) { const text = fs.readFileSync(file, 'utf8'); const stack = []; const tagRe = /<[^>]+>/gu; for (const match of text.matchAll(tagRe)) { const tag = match[0]; if (/^<\?/.test(tag) || /^