diff --git a/extensions/iterator.js b/extensions/iterator.js index 7577a49..59e7328 100644 --- a/extensions/iterator.js +++ b/extensions/iterator.js @@ -53,6 +53,7 @@ import { actionToCommand, activityTextFromMessage, attributionFromInput, + autoCompleteMessage, AUTO_PHASE_FOR_STEP, bundleExists, featuresDirEntries, @@ -821,11 +822,7 @@ export default function iteratorExtension(pi) { }); autoSteps = 0; await restoreModel(); - notifyUi( - sess.settings?.auto_retire_prompt === "on" - ? "auto mode: plan complete — every feature landed. Consider retiring the plan from the dashboard." - : "auto mode: plan complete — every feature landed.", - ); + notifyUi(autoCompleteMessage(sess.settings)); await refreshHub(cwd); return; } @@ -1161,7 +1158,7 @@ export default function iteratorExtension(pi) { } const cmd = actionToCommand(result); if (!cmd) return; - session.showWorking(`Dispatched ${cmd} — Claude is working…`); + session.showWorking(`Dispatched ${cmd} — Agent is working…`); dispatch(cmd); }, onControl, @@ -1411,6 +1408,17 @@ export default function iteratorExtension(pi) { // Work so their paths and target state are immediately visible. void refreshHub(ctx.cwd, { activateWork: true }); } + if (result?.autoCompleted) { + // The agent plan-review writer already made the terminal state + // durable. Converge the local UI now instead of waiting for an + // agent-end callback that may race an iframe refresh. + autoSteps = 0; + await restoreModel(); + session?.clearWorking?.(); + const { settings } = await gatherSession(ctx.cwd); + notifyUi(autoCompleteMessage(settings)); + await refreshHub(ctx.cwd, { activateWork: true }); + } if (approved) { void refreshHub(ctx.cwd, { activateWork: true }); try { diff --git a/lib/gather.mjs b/lib/gather.mjs index f13f78e..399b237 100644 --- a/lib/gather.mjs +++ b/lib/gather.mjs @@ -38,7 +38,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; import { git } from "./git.mjs"; -import { effectiveSettings, parseState } from "./settings.mjs"; +import { effectiveSettings, parseState, SETTINGS_DEFS } from "./settings.mjs"; import { planStage, readiness, satisfiedSet } from "./status.mjs"; import { backlogItems, @@ -934,7 +934,9 @@ export function gatherUsage(startDir) { const b = loadBundle(startDir); const data = usageDataAt(join(b.memDir, "usage.md")); const totals = data?.totals || { steps: {}, features: {}, featureModels: {} }; - const prices = data?.prices || {}; + // A non-null project catalog is authoritative, including an explicit empty + // table. Older active ledgers keep working only until Budget saves a catalog. + const prices = b.settings.usage_prices ?? data?.prices ?? {}; return { step: "usage", branch: b.branch, @@ -1068,7 +1070,9 @@ export function gatherSettings(startDir) { plan: b.plan?.fm.title || null, // Effective values (defaults merged) — the view renders and edits these; // `defined` says whether memory/settings.md exists yet. - settings: b.settings, + settings: Object.fromEntries( + Object.entries(b.settings).filter(([key]) => !SETTINGS_DEFS[key]?.hidden), + ), defined: b.settingsDefined, state: b.state, }; diff --git a/lib/pi-tools.mjs b/lib/pi-tools.mjs index 3279f78..060ebeb 100644 --- a/lib/pi-tools.mjs +++ b/lib/pi-tools.mjs @@ -268,6 +268,13 @@ export const AUTO_PHASE_FOR_STEP = { "plan-review": "reviewing", }; +/** The shared terminal notification for both normal and immediate auto completion. */ +export function autoCompleteMessage(settings) { + return settings?.auto_retire_prompt === "on" + ? "auto mode: plan complete — every feature landed. Consider retiring the plan from the dashboard." + : "auto mode: plan complete — every feature landed."; +} + /** Preserve the active item when a ready wave is paused mid-turn. */ export function pauseFeatureWave(wave) { if (!wave || !Array.isArray(wave.queue) || !Array.isArray(wave.results)) diff --git a/lib/server.mjs b/lib/server.mjs index 9f233ad..f947770 100644 --- a/lib/server.mjs +++ b/lib/server.mjs @@ -60,46 +60,71 @@ * forward. ITERATOR_REMOTE=1/0 overrides detection; ITERATOR_BIND_HOST * overrides the bind address. */ -import http from 'node:http'; -import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import http from "node:http"; +import { readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { - BIND_HOST, CANCEL_GRACE_MS, EXPOSED, LOCAL_HOST_RE, REMOTE, STATUS_PATH, - TIMEOUT_MS, displayPort, displayUrl, openBrowser, -} from './server/env.mjs'; -import { RUN_ID } from './server/run-id.mjs'; -import { listenWithTakeover } from './server/listen.mjs'; -import { registryPath, takeoverStale } from './server/takeover.mjs'; + BIND_HOST, + CANCEL_GRACE_MS, + EXPOSED, + LOCAL_HOST_RE, + REMOTE, + STATUS_PATH, + TIMEOUT_MS, + displayPort, + displayUrl, + openBrowser, +} from "./server/env.mjs"; +import { RUN_ID } from "./server/run-id.mjs"; +import { listenWithTakeover } from "./server/listen.mjs"; +import { registryPath, takeoverStale } from "./server/takeover.mjs"; // The public surface is unchanged: everything the modules own is re-exported // here (live bindings included), so session-server.mjs, app.mjs, tests, and // the skill shims keep their single import point. export { - BIND_HOST, CANCEL_GRACE_MS, DISPLAY_HOST, EXPOSED, FORCE_PORT, LOCAL_HOST_RE, - REMOTE, STATUS_PATH, TIMEOUT_MS, displayPort, displayUrl, isRemoteSession, - openBrowser, -} from './server/env.mjs'; -export { RUN_ID, newRunId } from './server/run-id.mjs'; -export { listenWithTakeover } from './server/listen.mjs'; -export { reclaimPort, registryPath, takeoverStale } from './server/takeover.mjs'; + BIND_HOST, + CANCEL_GRACE_MS, + DISPLAY_HOST, + EXPOSED, + FORCE_PORT, + LOCAL_HOST_RE, + REMOTE, + STATUS_PATH, + TIMEOUT_MS, + displayPort, + displayUrl, + isRemoteSession, + openBrowser, +} from "./server/env.mjs"; +export { RUN_ID, newRunId } from "./server/run-id.mjs"; +export { listenWithTakeover } from "./server/listen.mjs"; +export { + reclaimPort, + registryPath, + takeoverStale, +} from "./server/takeover.mjs"; /** Read all of stdin as a string, resolving with '' if nothing arrives. */ export function readStdin() { - return new Promise(resolve => { - let raw = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', c => (raw += c)); - process.stdin.on('end', () => resolve(raw)); - // If stdin is a TTY with no piped data, don't hang forever. - if (process.stdin.isTTY) resolve(''); - }); + return new Promise((resolve) => { + let raw = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (raw += c)); + process.stdin.on("end", () => resolve(raw)); + // If stdin is a TTY with no piped data, don't hang forever. + if (process.stdin.isTTY) resolve(""); + }); } /** Parse a JSON payload from stdin (defaults to {}). */ export async function readPayload() { - const raw = await readStdin(); - try { return JSON.parse(raw || '{}'); } - catch { return {}; } + const raw = await readStdin(); + try { + return JSON.parse(raw || "{}"); + } catch { + return {}; + } } /** @@ -118,180 +143,218 @@ export async function readPayload() { * summaries attached to cancel/timeout results as `report` — the skill * relays the string instead of carrying per-step cancel prose itself. */ -export async function serve({ step = 'iterator', html, onSubmit, reports = {} }) { - const startPort = parseInt(process.env.ITERATOR_PORT || '7777', 10); - const MAX_PORT_RETRIES = 20; - const regPath = registryPath(); - let done = false; - let cancelTimer = null; +export async function serve({ + step = "iterator", + html, + onSubmit, + reports = {}, +}) { + const startPort = parseInt(process.env.ITERATOR_PORT || "7777", 10); + const MAX_PORT_RETRIES = 20; + const regPath = registryPath(); + let done = false; + let cancelTimer = null; - const finish = (obj, exitCode = 0) => { - if (done) return; - done = true; - if (obj) process.stdout.write(JSON.stringify(obj) + '\n'); - try { - const cur = JSON.parse(readFileSync(regPath, 'utf8')); - if (cur && cur.pid === process.pid) unlinkSync(regPath); - } catch {} - try { server.close(); } catch {} - process.exit(exitCode); - }; + const finish = (obj, exitCode = 0) => { + if (done) return; + done = true; + if (obj) process.stdout.write(JSON.stringify(obj) + "\n"); + try { + const cur = JSON.parse(readFileSync(regPath, "utf8")); + if (cur && cur.pid === process.pid) unlinkSync(regPath); + } catch {} + try { + server.close(); + } catch {} + process.exit(exitCode); + }; - // A superseded/interrupted server must free the port *and* still honor the - // one-JSON-line contract, so signals resolve as a cancel. - const cancelResult = () => ({ - type: 'cancel', - ...(reports.cancel ? { report: reports.cancel } : {}), - }); + // A superseded/interrupted server must free the port *and* still honor the + // one-JSON-line contract, so signals resolve as a cancel. + const cancelResult = () => ({ + type: "cancel", + ...(reports.cancel ? { report: reports.cancel } : {}), + }); - for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) { - process.on(sig, () => finish(cancelResult())); - } + for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) { + process.on(sig, () => finish(cancelResult())); + } - const server = http.createServer((req, res) => { - const url = new URL(req.url, 'http://127.0.0.1'); - if (req.method === 'GET' && url.pathname === STATUS_PATH) { - // Tokenless on purpose: the successor server uses this to verify the - // port holder is a lingering iterator UI before signalling it. It is - // read-only and reveals nothing sensitive. - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ app: 'iterator', step, pid: process.pid })); - return; - } - // No per-run token (dev tool; matches okf-memory) — but locally we still - // reject non-localhost Host headers so DNS rebinding can't reach us. - if (!EXPOSED && !LOCAL_HOST_RE.test(String(req.headers.host || ''))) { - res.writeHead(403, { 'Content-Type': 'text/plain' }); - res.end('Forbidden'); - return; - } - if (req.method === 'GET' && url.pathname === '/') { - // A GET during the cancel grace period is the page reloading — keep going. - if (cancelTimer) { clearTimeout(cancelTimer); cancelTimer = null; } - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(html); - } else if (req.method === 'POST' && url.pathname === '/submit') { - const r = url.searchParams.get('r'); - if (r && r !== RUN_ID) { - process.stderr.write('iterator: ignored /submit from a previous run’s tab\n'); - res.writeHead(409); res.end(); - return; - } - let body = ''; - req.on('data', c => (body += c)); - req.on('end', async () => { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(doneHtml()); - if (!done) { - done = true; - // Parse-validate before printing: the stdout line is a one-JSON-line - // contract, and any local process can POST garbage here. - let parsed; - try { - parsed = JSON.parse(body.trim() || '{}'); - } catch { - parsed = { type: 'error', error: 'malformed /submit body (not JSON)' }; - } - if (onSubmit && parsed.type !== 'error') { - try { - const transformed = await onSubmit(parsed); - if (transformed) parsed = transformed; - } catch (e) { - parsed.applied = { ok: false, error: e.message }; - } - } - process.stdout.write(JSON.stringify(parsed) + '\n'); - try { server.close(); } catch {} - // Give the socket a tick to flush before exiting. - setTimeout(() => process.exit(0), 30).unref(); - } - }); - } else if (req.method === 'POST' && url.pathname === '/cancel') { - const r = url.searchParams.get('r'); - let body = ''; - req.on('data', c => (body += c)); - req.on('end', () => { - res.writeHead(204); res.end(); - if (r && r !== RUN_ID) { - process.stderr.write('iterator: ignored /cancel from a previous run’s tab\n'); - return; - } - if (done || cancelTimer) return; - if (url.searchParams.get('now') === '1') { finish(cancelResult()); return; } - cancelTimer = setTimeout(() => finish(cancelResult()), CANCEL_GRACE_MS); - }); - } else { - res.writeHead(404); res.end(); - } - }); + const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + if (req.method === "GET" && url.pathname === STATUS_PATH) { + // Tokenless on purpose: the successor server uses this to verify the + // port holder is a lingering iterator UI before signalling it. It is + // read-only and reveals nothing sensitive. + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ app: "iterator", step, pid: process.pid })); + return; + } + // No per-run token (dev tool; matches okf-memory) — but locally we still + // reject non-localhost Host headers so DNS rebinding can't reach us. + if (!EXPOSED && !LOCAL_HOST_RE.test(String(req.headers.host || ""))) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Forbidden"); + return; + } + if (req.method === "GET" && url.pathname === "/") { + // A GET during the cancel grace period is the page reloading — keep going. + if (cancelTimer) { + clearTimeout(cancelTimer); + cancelTimer = null; + } + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } else if (req.method === "POST" && url.pathname === "/submit") { + const r = url.searchParams.get("r"); + if (r && r !== RUN_ID) { + process.stderr.write( + "iterator: ignored /submit from a previous run’s tab\n", + ); + res.writeHead(409); + res.end(); + return; + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", async () => { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(doneHtml()); + if (!done) { + done = true; + // Parse-validate before printing: the stdout line is a one-JSON-line + // contract, and any local process can POST garbage here. + let parsed; + try { + parsed = JSON.parse(body.trim() || "{}"); + } catch { + parsed = { + type: "error", + error: "malformed /submit body (not JSON)", + }; + } + if (onSubmit && parsed.type !== "error") { + try { + const transformed = await onSubmit(parsed); + if (transformed) parsed = transformed; + } catch (e) { + parsed.applied = { ok: false, error: e.message }; + } + } + process.stdout.write(JSON.stringify(parsed) + "\n"); + try { + server.close(); + } catch {} + // Give the socket a tick to flush before exiting. + setTimeout(() => process.exit(0), 30).unref(); + } + }); + } else if (req.method === "POST" && url.pathname === "/cancel") { + const r = url.searchParams.get("r"); + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + res.writeHead(204); + res.end(); + if (r && r !== RUN_ID) { + process.stderr.write( + "iterator: ignored /cancel from a previous run’s tab\n", + ); + return; + } + if (done || cancelTimer) return; + if (url.searchParams.get("now") === "1") { + finish(cancelResult()); + return; + } + cancelTimer = setTimeout(() => finish(cancelResult()), CANCEL_GRACE_MS); + }); + } else { + res.writeHead(404); + res.end(); + } + }); - const onListen = () => { - const { port } = server.address(); - // Record ourselves so the next server can find and replace us even if we - // are never answered (the port-leak fix: no more orphans holding 7777). - try { - writeFileSync(regPath, JSON.stringify( - { pid: process.pid, port, step, started: new Date().toISOString() }, - ) + '\n', { mode: 0o600 }); - } catch {} - // Always display localhost (ITERATOR_DISPLAY_HOST overrides), never the - // bind address — 0.0.0.0 is not a clickable URL, and through a forward the - // host reaches us on its own loopback anyway. ITERATOR_DISPLAY_PORT swaps - // in the host-side port when the sandbox publish maps a different one. - const url = displayUrl(port); - openBrowser(url); - process.stderr.write(`iterator: ${step} listening on ${url}\n`); - if (REMOTE) { - const hostPort = displayPort(port); - process.stderr.write( - `iterator: remote session — bound to ${BIND_HOST}. Forward/publish port ${port} ` + - `to the host loopback (e.g. sbx ports --publish ${hostPort}:${port}, ` + - `docker run -p 127.0.0.1:${hostPort}:${port}, or ssh -L ${hostPort}:localhost:${port}), ` + - `then open the URL above in the host browser.\n`); - } - if (EXPOSED) { - process.stderr.write( - `iterator: WARNING — listening on ${BIND_HOST}: anyone who can reach this ` + - `port can answer as the user. Keep the host-side publish on loopback ` + - `(127.0.0.1:${displayPort(port)}, not 0.0.0.0:${displayPort(port)}).\n`); - } - }; + const onListen = () => { + const { port } = server.address(); + // Record ourselves so the next server can find and replace us even if we + // are never answered (the port-leak fix: no more orphans holding 7777). + try { + writeFileSync( + regPath, + JSON.stringify({ + pid: process.pid, + port, + step, + started: new Date().toISOString(), + }) + "\n", + { mode: 0o600 }, + ); + } catch {} + // Always display localhost (ITERATOR_DISPLAY_HOST overrides), never the + // bind address — 0.0.0.0 is not a clickable URL, and through a forward the + // host reaches us on its own loopback anyway. ITERATOR_DISPLAY_PORT swaps + // in the host-side port when the sandbox publish maps a different one. + const url = displayUrl(port); + openBrowser(url); + process.stderr.write(`iterator: ${step} listening on ${url}\n`); + if (REMOTE) { + const hostPort = displayPort(port); + process.stderr.write( + `iterator: remote session — bound to ${BIND_HOST}. Forward/publish port ${port} ` + + `to the host loopback (e.g. sbx ports --publish ${hostPort}:${port}, ` + + `docker run -p 127.0.0.1:${hostPort}:${port}, or ssh -L ${hostPort}:localhost:${port}), ` + + `then open the URL above in the host browser.\n`, + ); + } + if (EXPOSED) { + process.stderr.write( + `iterator: WARNING — listening on ${BIND_HOST}: anyone who can reach this ` + + `port can answer as the user. Keep the host-side publish on loopback ` + + `(127.0.0.1:${displayPort(port)}, not 0.0.0.0:${displayPort(port)}).\n`, + ); + } + }; - // Replace a lingering iterator UI before binding, so consecutive runs stay - // on the same fixed port (ITERATOR_NO_TAKEOVER=1 opts out, e.g. in tests). - if (!process.env.ITERATOR_NO_TAKEOVER) await takeoverStale(regPath); + // Replace a lingering iterator UI before binding, so consecutive runs stay + // on the same fixed port (ITERATOR_NO_TAKEOVER=1 opts out, e.g. in tests). + if (!process.env.ITERATOR_NO_TAKEOVER) await takeoverStale(regPath); - listenWithTakeover(server, { - startPort, - maxRetries: MAX_PORT_RETRIES, - onReclaimFail: (r, port) => { - if (r.reason === 'session') { - process.stderr.write( - `iterator: session dashboard owns port ${port} — open ` + - `${displayUrl(port)} on the host; this one-shot walks up\n`); - } else { - process.stderr.write( - `iterator: could not reclaim port ${port} — walking up (the UI ` + - `may be unreachable through the sandbox's port publish)\n`); - } - }, - }).then(onListen).catch(err => { - process.stderr.write(`iterator: server error: ${err.message}\n`); - finish(null, 1); - }); + listenWithTakeover(server, { + startPort, + maxRetries: MAX_PORT_RETRIES, + onReclaimFail: (r, port) => { + if (r.reason === "session") { + process.stderr.write( + `iterator: session dashboard owns port ${port} — open ` + + `${displayUrl(port)} on the host; this one-shot walks up\n`, + ); + } else { + process.stderr.write( + `iterator: could not reclaim port ${port} — walking up (the UI ` + + `may be unreachable through the sandbox's port publish)\n`, + ); + } + }, + }) + .then(onListen) + .catch((err) => { + process.stderr.write(`iterator: server error: ${err.message}\n`); + finish(null, 1); + }); - setTimeout(() => { - process.stderr.write('iterator: timeout (2h), no response received\n'); - finish({ - type: 'timeout', - ...(reports.timeout ? { report: reports.timeout } : {}), - }); - }, TIMEOUT_MS).unref(); + setTimeout(() => { + process.stderr.write("iterator: timeout (2h), no response received\n"); + finish({ + type: "timeout", + ...(reports.timeout ? { report: reports.timeout } : {}), + }); + }, TIMEOUT_MS).unref(); } /** The little "you can close this tab" page shown after a submit. */ -export function doneHtml(msg = 'Sent to Claude') { - return ` diff --git a/lib/session-server.mjs b/lib/session-server.mjs index 38c83b0..fc406ff 100644 --- a/lib/session-server.mjs +++ b/lib/session-server.mjs @@ -547,7 +547,7 @@ export function createSessionServer({ onUnsolicited, onControl, log } = {}) { if (pending && !modalResult) { // The interactive view temporarily hid this agent's overlay. Resume // the same ownership claim so its eventual agent_end can release it. - setWorking("Sent to Claude — working…", pending.workingOwner); + setWorking("Sent to Agent — working…", pending.workingOwner); settle(parsed); } else if (onUnsolicited) { onUnsolicited(parsed); diff --git a/lib/settings.mjs b/lib/settings.mjs index dcf2f6e..c2747f0 100644 --- a/lib/settings.mjs +++ b/lib/settings.mjs @@ -13,7 +13,7 @@ * time — this module never talks to a model registry. */ -/** Key definitions: kind (enum|int|model), allowed values / bounds, default. */ +/** Key definitions: kind (enum|int|model|prices), allowed values / bounds, default. */ export const SETTINGS_DEFS = { auto_mode: { kind: "enum", @@ -155,6 +155,17 @@ export const SETTINGS_DEFS = { label: "Token usage ledger", help: "Record per-step model/token usage into memory/usage.md (pi sessions only).", }, + // This is project configuration, but Budget is its only editor: keeping it + // hidden prevents the generic Settings form from exposing serialized rates. + // null means no project catalog has been saved; {} deliberately means the + // user cleared it, so usage.md may not supply a legacy fallback. + usage_prices: { + kind: "prices", + default: null, + hidden: true, + label: "Model prices", + help: "Project-owned USD-per-million model rates, edited in Budget.", + }, auto_retire_prompt: { kind: "enum", values: ["on", "off"], @@ -172,6 +183,77 @@ export const SETTINGS_DEFS = { }; export const SETTINGS_KEYS = Object.keys(SETTINGS_DEFS); +export const USAGE_PRICE_FIELDS = [ + "input", + "output", + "cacheRead", + "cacheWrite", +]; + +/** + * Validate and normalize a complete project-owned model-rate catalog. Accept + * parsed objects from the Budget writer and JSON scalars from settings.md. + */ +export function validateUsagePrices(raw) { + let value = raw; + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + return { ok: false, errors: ["usage prices must be valid JSON"] }; + } + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { + ok: false, + errors: ["usage prices must be an object keyed by provider/model"], + }; + } + const prices = {}; + for (const [rawModel, rawRates] of Object.entries(value)) { + const model = String(rawModel).trim(); + if ( + !model || + !model.includes("/") || + model.length > 200 || + /\s/.test(model) + ) { + return { + ok: false, + errors: [ + `usage price model '${rawModel}' must be a non-empty provider/model without whitespace`, + ], + }; + } + if (!rawRates || typeof rawRates !== "object" || Array.isArray(rawRates)) { + return { + ok: false, + errors: [`usage prices for ${model} must be an object`], + }; + } + const rates = {}; + for (const [field, rawRate] of Object.entries(rawRates)) { + if (!USAGE_PRICE_FIELDS.includes(field)) { + return { + ok: false, + errors: [`usage prices for ${model}: unknown token field '${field}'`], + }; + } + const rate = Number(rawRate); + if (!Number.isFinite(rate) || rate < 0) { + return { + ok: false, + errors: [ + `usage prices for ${model}: ${field} must be a non-negative number`, + ], + }; + } + rates[field] = rate; + } + if (Object.keys(rates).length) prices[model] = rates; + } + return { ok: true, errors: [], prices }; +} /** The full defaults object. */ export function settingsDefaults() { @@ -218,6 +300,13 @@ export function validateSettings(partial) { continue; } values[key] = s; + } else if (def.kind === "prices") { + const checked = validateUsagePrices(raw); + if (!checked.ok) { + errors.push(...checked.errors.map((error) => `${key}: ${error}`)); + continue; + } + values[key] = checked.prices; } } return { ok: errors.length === 0, errors, values }; diff --git a/lib/ui.mjs b/lib/ui.mjs index 0cc346b..2250324 100644 --- a/lib/ui.mjs +++ b/lib/ui.mjs @@ -188,7 +188,7 @@ function primaryClick(){ if(typeof onPrimary==='function') onPrimary(); } // round-trip actions (split/merge). Shows sending state on the primary button. async function post(payload, okMsg, options){ if(document.body.classList.contains('iterator-ro') && !options?.allowWhileWorking){ - alert('Claude is working — actions are disabled until it finishes.'); + alert('Agent is working — actions are disabled until it finishes.'); return false; } var btn=document.getElementById('primary'); @@ -200,10 +200,10 @@ async function post(payload, okMsg, options){ // Busy (agent working) or stale round — the action was NOT accepted. __submitted = false; if(btn){ btn.disabled=false; if(typeof refresh==='function') refresh(); else btn.textContent=btn.dataset.prev||'Accept'; } - alert('Not sent — Claude is still working (or this view is stale). Try again when the dashboard refreshes.'); + alert('Not sent — Agent is still working (or this view is stale). Try again when the dashboard refreshes.'); return false; } - if(btn) btn.textContent = '✓ ' + (okMsg||'Sent to Claude'); + if(btn) btn.textContent = '✓ ' + (okMsg||'Sent to Agent'); return true; }catch(e){ __submitted=false; @@ -320,7 +320,7 @@ function header(subtitle, showPrimary, idleLabel, showCancel) { '\n' + '
' + (showCancel - ? '' + ? '' : "") + (showPrimary ? '
'; // Handlers are wired with closures (never inline attribute strings), so // feature names containing quotes/backslashes can't break or inject markup. @@ -184,20 +184,20 @@ function updateDesc(name,desc){ function setComment(name,val){ val=val.trim(); if(val) S.comments[name]=val; else delete S.comments[name]; refresh(); } function splitFeature(name){ const c=S.features.find(c=>c.name===name); if(!c) return; - if(!confirm('Split "'+name+'"? Claude will split it into single-feature features and reopen this view.')) return; - post({type:'split-request', branch:D.branch||'HEAD', feature:name, content:JSON.stringify(c)}, 'Splitting — Claude is working…'); + if(!confirm('Split "'+name+'"? The Agent will split it into single-feature features and reopen this view.')) return; + post({type:'split-request', branch:D.branch||'HEAD', feature:name, content:JSON.stringify(c)}, 'Splitting — Agent is working…'); } function toggleMerge(name){ S.mergeSel = S.mergeSel===name?null:name; renderCards(); } function completeMerge(target){ const a=S.mergeSel; if(!a||a===target){ S.mergeSel=null; renderCards(); return; } - if(!confirm('Merge "'+a+'" and "'+target+'"? Claude will combine them and reopen this view.')) return; - post({type:'merge-request', branch:D.branch||'HEAD', features:[a,target]}, 'Merging — Claude is working…'); + if(!confirm('Merge "'+a+'" and "'+target+'"? The Agent will combine them and reopen this view.')) return; + post({type:'merge-request', branch:D.branch||'HEAD', features:[a,target]}, 'Merging — Agent is working…'); } function collectComments(){ return Object.entries(S.comments).map(([feature,comment])=>({feature,comment})); } function hasChanges(){ return !!(S.moves.length||S.renames.length||S.descUpdates.length||collectComments().length); } function onPrimary(){ if(hasChanges()){ - post({type:'plan-adjustments', branch:D.branch||'HEAD', moves:S.moves, renames:S.renames, descUpdates:S.descUpdates, comments:collectComments()}, 'Sent — Claude is updating the features'); + post({type:'plan-adjustments', branch:D.branch||'HEAD', moves:S.moves, renames:S.renames, descUpdates:S.descUpdates, comments:collectComments()}, 'Sent — Agent is updating the features'); } else { post({type:'plan-approved', branch:D.branch||'HEAD'}, 'Features accepted — run /iterator-implement to build them'); } @@ -205,9 +205,16 @@ function onPrimary(){ `; export function render(data) { - return renderPage({ - step: 'feature', subtitle: '/ features', branch: data.branch, title: data.plan, - data, css: FEATURE_CSS + GRAPH_CSS, body: FEATURE_BODY, clientJs: GRAPH_JS + FEATURE_JS, - primaryIdle: 'Accept', primaryChanged: 'Send review', - }); + return renderPage({ + step: "feature", + subtitle: "/ features", + branch: data.branch, + title: data.plan, + data, + css: FEATURE_CSS + GRAPH_CSS, + body: FEATURE_BODY, + clientJs: GRAPH_JS + FEATURE_JS, + primaryIdle: "Accept", + primaryChanged: "Send review", + }); } diff --git a/lib/views/memory-review.mjs b/lib/views/memory-review.mjs index 8a1642f..cd6dd9d 100644 --- a/lib/views/memory-review.mjs +++ b/lib/views/memory-review.mjs @@ -117,7 +117,7 @@ function cardHtml(m) {
${verdictButtons(m)}
+ placeholder="Comment — sends this memory back to the Agent for revision">
`; } @@ -313,7 +313,7 @@ ${areaSections({ ...data, mode }, memories)} -

Any comment sends the review back to Claude for another round. +

Any comment sends the review back to the Agent for another round. With no comments, the header button accepts the verdicts as chosen above.

`; diff --git a/lib/views/plan.mjs b/lib/views/plan.mjs index 470bb12..ce37037 100644 --- a/lib/views/plan.mjs +++ b/lib/views/plan.mjs @@ -85,7 +85,7 @@ const PLAN_BODY = `

-

Each section is rendered markdown — click any section to edit (save with blur or ⌘/Ctrl+Enter). Use the 💬 icon to leave a comment on a section, and confirm the external dependencies below — new packages, libraries, or services this plan needs, never tasks or todos. When everything looks right click Accept; if you edit anything or add a comment the button becomes Send review so Claude can revise first.

+

Each section is rendered markdown — click any section to edit (save with blur or ⌘/Ctrl+Enter). Use the 💬 icon to leave a comment on a section, and confirm the external dependencies below — new packages, libraries, or services this plan needs, never tasks or todos. When everything looks right click Accept; if you edit anything or add a comment the button becomes Send review so the Agent can revise first.

External dependencies
@@ -94,7 +94,7 @@ const PLAN_BODY = `
Global comment (optional)
@@ -106,7 +106,7 @@ const PLAN_BODY = `
-
@@ -236,7 +236,7 @@ function onPrimary(){ dependencies: deps, comments: collectComments(), comment: document.getElementById('global-comment').value.trim(), - }, changed ? 'Review sent to Claude' : 'Plan approved'); + }, changed ? 'Review sent to Agent' : 'Plan approved'); } `; diff --git a/lib/views/question.mjs b/lib/views/question.mjs index aa727f9..8e00e01 100644 --- a/lib/views/question.mjs +++ b/lib/views/question.mjs @@ -14,7 +14,7 @@ * { type:"answer", choice:"