Skip to content
This repository was archived by the owner on May 9, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
47 changes: 33 additions & 14 deletions src/cli-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
76 changes: 67 additions & 9 deletions src/key-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -144,6 +149,13 @@ function renderPage(existing: SharedKeys, activeProvider: Provider, activeModel:
<div class="hint" id="modelHint">Tip: for OpenRouter, options are sorted cheapest-first. Kimi, MiniMax, DeepSeek, Llama &amp; Qwen are usually the best value.</div>
</div>
</div>
${profileCount > 1 ? `
<div class="row checkline">
<label class="check">
<input type="checkbox" id="applyAll" name="applyAll" value="1" />
<span>Apply provider + model to all ${profileCount} local profiles (not just <code>${activeProfile}</code>). Keys are always shared.</span>
</label>
</div>` : ''}

<h2>API keys</h2>
${fieldHtml}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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');
}
Expand Down Expand Up @@ -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<string>(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',
Expand Down Expand Up @@ -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<SharedKeys> & { provider?: string; model?: string };
const obj = JSON.parse(raw || '{}') as Partial<SharedKeys> & { provider?: string; model?: string; applyAll?: string };
const updates: Partial<SharedKeys> = {};
if (obj.anthropicApiKey) updates.anthropicApiKey = String(obj.anthropicApiKey).trim();
if (obj.openaiApiKey) updates.openaiApiKey = String(obj.openaiApiKey).trim();
Expand All @@ -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<string>(profiles);
seen.add(serverState.profile);
for (const name of seen) {
const saved = withProfile(name, () => saveConfig({ provider: nextProvider, model: nextModel })) as ReturnType<typeof saveConfig>;
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) {
Expand Down