From 92908e55e8a186d4ecfcd7bbff1f80be9b3e6f86 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:13:00 -0700 Subject: [PATCH] feat: agent 0.12.10 (honest /sync + bulk model switch in /keys) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs the user hit after /sync'ing 6 agents down from krawler.com: 1. /sync was lying. "kicked a cycle" printed unconditionally, but the underlying armProfile() silently returned `idle, reason: "missing X creds"` if the profile's provider had no matching key in shared-keys.json — which is the common case for freshly-synced agents that inherit provider=anthropic but have no Anthropic key pasted yet. Now /sync awaits armProfile and surfaces the real outcome: "already local — cannot heartbeat (missing anthropic creds). Run /keys to add the missing key." Same treatment for newly-created profiles (line 108): if arm fails, the emitted row is "created locally (cannot heartbeat yet: ...)" instead of pretending success. 2. Model switch was per-profile-at-a-time. Spawn 10 agents on krawler, /sync creates 10 profiles each inheriting Opus 4.7, realise Opus hurts your wallet, you had to /switch + /keys + save ten times to move everyone off. New "Apply provider + model to all N local profiles" checkbox in the /keys page fans the save across every profile in one round-trip. Keys are still shared (one shared-keys.json per machine); only the per-profile provider/model gets fanned out. Internal: the wizard's client-side form serializer now special-cases checkbox.type — previously el.value returned '1' regardless of checked state, which would have sent every checkbox as checked. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/cli-sync.ts | 47 ++++++++++++++++++++--------- src/key-wizard.ts | 76 +++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 101 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index a69feb0..a7a764f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@krawlerhq/agent", - "version": "0.12.9", + "version": "0.12.10", "description": "Your personal AI agent, living locally, with a public identity on Krawler. Chat with it in the terminal; it posts, follows, endorses, remembers, and learns. Bring your own model (Anthropic, OpenAI, Google, OpenRouter, Ollama).", "keywords": [ "krawler", diff --git a/src/cli-sync.ts b/src/cli-sync.ts index 9759ccc..d3400eb 100644 --- a/src/cli-sync.ts +++ b/src/cli-sync.ts @@ -94,27 +94,46 @@ export async function syncPlatformAgents( // armed it yet — this is the common case when /sync runs after // the user spawned an agent on krawler.com and the krawler CLI // was already running (boot-time pump walked the old profile - // list). Fire armProfile unconditionally; it's idempotent - // enough — scheduleNext checks the active-timers map before - // arming a second timer, and running an extra runHeartbeat is - // exactly what the user wants ("first post, now"). Cycle - // progress surfaces in chat via the pumpEvents bus. - void armProfile(a.handle).catch(() => { /* non-fatal */ }); - const o: SyncOutcome = { profile: a.handle, handle: a.handle, state: 'skipped', reason: 'already local \u2014 kicked a cycle' }; + // list). Await armProfile so the /sync log surfaces the ACTUAL + // outcome (was 'kicked a cycle' unconditionally through 0.12.9, + // which silently lied when armProfile's pre-flight rejected + // missing creds — user saw green log lines but the dashboard + // kept showing "sleeping" because no heartbeat ever fired). + let reason = 'already local'; + try { + const status = await armProfile(a.handle); + reason = status.state === 'pumping' + ? 'already local \u2014 kicked a cycle' + : `already local \u2014 cannot heartbeat (${status.reason}). Run /keys to add the missing key.`; + } catch (e) { + reason = `already local \u2014 arm failed: ${(e as Error).message}`; + } + const o: SyncOutcome = { profile: a.handle, handle: a.handle, state: 'skipped', reason }; outcomes.push(o); onStep?.(o); continue; } try { const issued = await client.issueCliKey(auth.token, a.handle); writeProfileConfig(a.handle, issued.apiKey); - const o: SyncOutcome = { profile: a.handle, handle: a.handle, state: 'created' }; - outcomes.push(o); onStep?.(o); // Fire the first cycle immediately so the human doesn't wait a // full cadence before the "Post for the first time" setup step - // turns green. armProfile is non-blocking under the hood — it - // kicks runHeartbeat in the background and returns after it's - // validated creds + resolved identity. Cycle progress surfaces - // in the chat via the pumpEvents bus. - void armProfile(a.handle).catch(() => { /* non-fatal */ }); + // turns green. armProfile is non-blocking on runHeartbeat itself + // (it kicks that in the background) but DOES await the creds + + // /me checks, so awaiting it here tells us if the new profile + // is ready to cycle. If not (e.g. no Anthropic key yet in + // shared-keys.json), we emit a 'created' outcome with a reason + // suffix so the human knows the agent was created locally but + // will sit idle until they add the missing key. + let note = ''; + try { + const status = await armProfile(a.handle); + if (status.state !== 'pumping') { + note = ` (cannot heartbeat yet: ${status.reason}. Run /keys to add the missing key.)`; + } + } catch { /* non-fatal — profile is still written */ } + const o: SyncOutcome = note + ? { profile: a.handle, handle: a.handle, state: 'skipped', reason: `created locally${note}` } + : { profile: a.handle, handle: a.handle, state: 'created' }; + outcomes.push(o); onStep?.(o); } catch (e) { const o: SyncOutcome = { profile: a.handle, handle: a.handle, state: 'failed', reason: (e as Error).message }; outcomes.push(o); onStep?.(o); diff --git a/src/key-wizard.ts b/src/key-wizard.ts index 8f0f86b..e1cfe47 100644 --- a/src/key-wizard.ts +++ b/src/key-wizard.ts @@ -31,6 +31,7 @@ import open from 'open'; import { loadConfig, loadSharedKeys, normalizeModelForProvider, PROVIDERS, saveConfig, saveSharedKeys } from './config.js'; import type { Provider, SharedKeys } from './config.js'; import { MODEL_SUGGESTIONS } from './model.js'; +import { listProfiles, withProfile } from './profile-context.js'; export interface WizardResult { saved: boolean; @@ -52,7 +53,7 @@ export const PREFERRED_WIZARD_PORTS = [4242, 4243, 4244] as const; // set — they can leave those blank to keep them. The active provider // and model come from the CURRENT profile's config.json so the form // opens on whatever the agent is actively using right now. -function renderPage(existing: SharedKeys, activeProvider: Provider, activeModel: string, activeProfile: string): string { +function renderPage(existing: SharedKeys, activeProvider: Provider, activeModel: string, activeProfile: string, profileCount: number): string { const mask = (k: string): string => { if (!k) return ''; if (k.length < 10) return '\u2022\u2022\u2022\u2022\u2022'; @@ -110,6 +111,10 @@ function renderPage(existing: SharedKeys, activeProvider: Provider, activeModel: h2:first-of-type { margin-top: 0; } .pair { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: end; } @media (max-width: 480px) { .pair { grid-template-columns: 1fr; } } + .checkline { margin-top: 12px; } + .check { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; font-weight: 500; font-size: 0.85rem; color: var(--text-2); text-transform: none; letter-spacing: 0; margin-bottom: 0; } + .check input { margin-top: 3px; accent-color: var(--brand); } + .check code { font-family: var(--mono); background: var(--bg); padding: 0 4px; border-radius: 3px; } .hint { font-size: 0.75rem; color: var(--text-3); margin-top: 4px; } .hint code { background: var(--bg); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); } .actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; } @@ -144,6 +149,13 @@ function renderPage(existing: SharedKeys, activeProvider: Provider, activeModel:
Tip: for OpenRouter, options are sorted cheapest-first. Kimi, MiniMax, DeepSeek, Llama & Qwen are usually the best value.
+ ${profileCount > 1 ? ` +
+ +
` : ''}

API keys

${fieldHtml} @@ -239,6 +251,13 @@ function renderPage(existing: SharedKeys, activeProvider: Provider, activeModel: const data = {}; for (const el of form.elements) { if (!el.name) continue; + if (el.type === 'checkbox') { + // Only send checked boxes. Without this branch, el.value is + // always '1' regardless of checked state, which would make + // every checkbox look checked server-side. + if (el.checked) data[el.name] = '1'; + continue; + } const v = (el.value || '').trim(); if (v) data[el.name] = v; } @@ -247,9 +266,13 @@ function renderPage(existing: SharedKeys, activeProvider: Provider, activeModel: const res = await fetch('/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!res.ok) throw new Error('save failed'); const body = await res.json().catch(function () { return {}; }); - const suffix = body && body.model ? ' model: ' + body.model : ''; + let suffix = ''; + if (body && body.model) { + const n = (body.appliedProfiles || []).length; + suffix = ' model: ' + body.model + (n > 1 ? ' \u00b7 applied to ' + n + ' profiles' : ''); + } setStatus('\u2713 saved.' + suffix + ' You can close this tab and return to the terminal.', 'ok'); - setTimeout(function () { try { window.close(); } catch (e) {} }, 1500); + setTimeout(function () { try { window.close(); } catch (e) {} }, 1800); } catch (err) { setStatus('save failed: ' + (err.message || 'unknown'), 'err'); } @@ -392,7 +415,13 @@ function handleRequest(req: import('node:http').IncomingMessage, res: import('no // on-disk state (useful if the user edited values out-of-band // between page loads, or switched profiles via env var). const cfg = loadConfig(); - const body = renderPage(loadSharedKeys(), cfg.provider, cfg.model, serverState.profile); + // Count distinct local profiles so the page can decide whether to + // show the "apply to all" checkbox (1 profile = nothing to fan to). + // The active profile is always counted even if listProfiles misses + // it (fresh install where config.json hasn't been written yet). + const profileSet = new Set(listProfiles()); + profileSet.add(serverState.profile); + const body = renderPage(loadSharedKeys(), cfg.provider, cfg.model, serverState.profile, profileSet.size); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', @@ -422,7 +451,7 @@ function handleRequest(req: import('node:http').IncomingMessage, res: import('no req.on('data', (chunk) => { raw += chunk.toString('utf8'); if (raw.length > 16384) req.destroy(); }); req.on('end', () => { try { - const obj = JSON.parse(raw || '{}') as Partial & { provider?: string; model?: string }; + const obj = JSON.parse(raw || '{}') as Partial & { provider?: string; model?: string; applyAll?: string }; const updates: Partial = {}; if (obj.anthropicApiKey) updates.anthropicApiKey = String(obj.anthropicApiKey).trim(); if (obj.openaiApiKey) updates.openaiApiKey = String(obj.openaiApiKey).trim(); @@ -439,21 +468,50 @@ function handleRequest(req: import('node:http').IncomingMessage, res: import('no // while provider=anthropic gets rewritten for the direct API // (and vice versa) — same repair path loadConfig() already // does, just at write time so the on-disk file is clean. + // + // When applyAll is set, fan the provider+model write out across + // every local profile. Common case: the human spawned 10 agents + // on krawler.com, /sync created 10 profiles (each inheriting + // provider=anthropic + model=claude-opus-4-7 from personal), + // Opus hurts, they want everyone on Kimi K2 in one click. Keys + // are ALWAYS shared (one shared-keys.json per machine), so the + // checkbox only governs the per-profile provider/model fields. let mergedProvider: Provider | undefined; let mergedModel: string | undefined; + let appliedProfiles: string[] = []; const wantsProvider = typeof obj.provider === 'string' && (PROVIDERS as readonly string[]).includes(obj.provider); const wantsModel = typeof obj.model === 'string' && obj.model.trim().length > 0; + const wantsApplyAll = Boolean(obj.applyAll); if (wantsProvider || wantsModel) { const current = loadConfig(); const nextProvider: Provider = wantsProvider ? (obj.provider as Provider) : current.provider; const nextModelRaw = wantsModel ? String(obj.model).trim() : current.model; const nextModel = normalizeModelForProvider(nextProvider, nextModelRaw); - const saved = saveConfig({ provider: nextProvider, model: nextModel }); - mergedProvider = saved.provider; - mergedModel = saved.model; + if (wantsApplyAll) { + const profiles = listProfiles(); + // If this is a fresh install with only the default profile + // on disk, listProfiles can return empty (no config.json + // yet). Fall back to the active profile so the save still + // lands somewhere observable. Always include the currently- + // active profile so its config.json gets updated even if + // the profile scan missed it (e.g. transient ENOENT). + const seen = new Set(profiles); + seen.add(serverState.profile); + for (const name of seen) { + const saved = withProfile(name, () => saveConfig({ provider: nextProvider, model: nextModel })) as ReturnType; + mergedProvider = saved.provider; + mergedModel = saved.model; + appliedProfiles.push(name); + } + } else { + const saved = saveConfig({ provider: nextProvider, model: nextModel }); + mergedProvider = saved.provider; + mergedModel = saved.model; + appliedProfiles = [serverState.profile]; + } } res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, provider: mergedProvider, model: mergedModel })); + res.end(JSON.stringify({ ok: true, provider: mergedProvider, model: mergedModel, appliedProfiles })); const waiters = serverState.waiters.splice(0); for (const w of waiters) w({ saved: true, keys: mergedKeys, url: serverState.url }); } catch (e) {