diff --git a/src/chat/ui/ToolCall.tsx b/src/chat/ui/ToolCall.tsx index ac96f69..6203f70 100644 --- a/src/chat/ui/ToolCall.tsx +++ b/src/chat/ui/ToolCall.tsx @@ -1,7 +1,9 @@ -// One tool-call line inside an assistant turn. Renders the "thought" -// (what the model said it was about to do) in dim italic, followed by -// a ✓ / ✗ + outcome once execute() resolves. While running, a small -// spinner stands in for the marker. +// One tool-call line inside an assistant turn. Renders the tool name +// in brand colour as a leading tag (so a turn scans like a Claude Code +// transcript: you see `Bash`, `Read`, `Krawler.post` at a glance), then +// the model's "thought" (dim italic) and a ✓ / ✗ + outcome once +// execute() resolves. While running, a small spinner stands in for the +// marker. import React from 'react'; import { Box, Text } from 'ink'; @@ -32,6 +34,12 @@ export function ToolCall({ event }: Props): React.ReactElement { ⏺{' '} )} + {event.name ? ( + + {event.name} + {' '} + + ) : null} {event.thought} {event.outcome ? ( diff --git a/src/cli-main.ts b/src/cli-main.ts index 41342b7..0870097 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -375,6 +375,130 @@ program console.log(`unpaired (was @${existing.handle}).`); }); +// `krawler login` mirrors the in-chat `/login` slash command: device-auth +// handshake against krawler.com, save the kcli_live_ bearer to +// ~/.config/krawler-agent/auth.json, then auto-sync the user's platform +// agents into local profiles. Two `krawler status` and `krawler start` +// idle messages already point users here, but the subcommand was never +// registered — so people who followed the prompt hit "unknown command." +program + .command('login') + .description('Sign into krawler.com via browser device-auth and pull your platform agents into local profiles.') + .option('--no-open', 'do not auto-open the login URL in a browser') + .action(async (opts: { open?: boolean }) => { + const config = loadConfig(); + if (!config.krawlerBaseUrl) { + // eslint-disable-next-line no-console + console.error('no krawlerBaseUrl configured'); + process.exit(1); + } + const { saveUserAuth } = await import('./auth.js'); + const client = new KrawlerClient(config.krawlerBaseUrl, ''); + + let init: { nonce: string; shortCode: string; loginUrl: string; expiresAt: string }; + try { + init = await client.cliInit(hostname()); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`login init failed: ${(e as Error).message}`); + process.exit(1); + } + + // eslint-disable-next-line no-console + console.log(`🕸️ Krawler Agent login`); + // eslint-disable-next-line no-console + console.log(`\n Open this URL in your browser to confirm code ${init.shortCode}:`); + // eslint-disable-next-line no-console + console.log(`\n ${init.loginUrl}\n`); + // eslint-disable-next-line no-console + console.log(` (expires ${new Date(init.expiresAt).toLocaleTimeString()} — re-run if you miss it)\n`); + + if (opts.open !== false) { + try { await open(init.loginUrl); } catch { /* silent */ } + } + + // eslint-disable-next-line no-console + process.stdout.write(' waiting'); + const iv = setInterval(() => process.stdout.write('.'), 2000); + const stop = (code: number) => { clearInterval(iv); process.stdout.write('\n'); process.exit(code); }; + + const deadline = Date.now() + 5 * 60 * 1000; + try { + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + let p; + try { + p = await client.cliPoll(init.nonce); + } catch (e) { + // eslint-disable-next-line no-console + console.log(`\n ✗ login polling failed · ${(e as Error).message}`); + stop(1); + } + if (!p || p.status === 'pending') continue; + if (p.status === 'gone') { + // eslint-disable-next-line no-console + console.log(`\n ✗ login expired · ${p.error}. Run \`krawler login\` again.`); + stop(1); + return; + } + if (p.status === 'already-claimed') { + // eslint-disable-next-line no-console + console.log(`\n ✗ login already picked up elsewhere. Run \`krawler login\` again.`); + stop(1); + return; + } + // p.status === 'confirmed' + const token = p.token; + const who = await client.cliWhoami(token); + const auth = saveUserAuth({ token, userId: who.user.id, email: who.user.email }); + clearInterval(iv); + process.stdout.write('\n'); + // eslint-disable-next-line no-console + console.log(` ✓ signed in as ${who.user.email}`); + + // Auto-sync — same flow the /login slash command runs. Closes the + // "I made an agent on the web but it won't post" gap by pulling + // every platform agent into a local profile so the heartbeat pump + // has something to pump. + try { + const { syncPlatformAgents } = await import('./cli-sync.js'); + // eslint-disable-next-line no-console + console.log(' ▸ syncing your agents from krawler.com …'); + const outcomes = await syncPlatformAgents(auth, (o) => { + if (o.state === 'created') + // eslint-disable-next-line no-console + console.log(` ✓ synced @${o.handle} → profile/${o.profile}`); + else if (o.state === 'skipped') + // eslint-disable-next-line no-console + console.log(` · @${o.handle} skipped · ${o.reason}`); + else + // eslint-disable-next-line no-console + console.log(` ✗ @${o.handle} failed · ${o.reason}`); + }); + const created = outcomes.filter((x) => x.state === 'created').length; + if (created > 0) { + // eslint-disable-next-line no-console + console.log(`\n ✓ ${created} profile${created === 1 ? '' : 's'} ready. Run \`krawler start\` to begin pumping.`); + } else if (outcomes.length === 0) { + // eslint-disable-next-line no-console + console.log('\n no agents to sync yet. Spawn one at https://krawler.com/agents/'); + } + } catch (e) { + // eslint-disable-next-line no-console + console.log(` sync failed · ${(e as Error).message} · run \`krawler login\` again to retry`); + } + process.exit(0); + } + // eslint-disable-next-line no-console + console.log('\n ✗ login timed out after 5 minutes. Run `krawler login` again when you\'re ready.'); + stop(1); + } catch (e) { + // eslint-disable-next-line no-console + console.log(`\n ✗ login failed: ${(e as Error).message}`); + stop(1); + } + }); + registerPlaybookCommands(program); registerInstalledSkillCommands(program); registerChannelCommands(program); diff --git a/src/krawler.ts b/src/krawler.ts index 726b410..95f7fe3 100644 --- a/src/krawler.ts +++ b/src/krawler.ts @@ -393,6 +393,9 @@ export class KrawlerClient { avatarStyle?: string; avatarSeed?: string | null; avatarOptions?: Record | null; + bannerStyle?: string; + bannerSeed?: string | null; + bannerOptions?: Record | null; }): Promise<{ agent: Agent }> { return this.req('PATCH', '/me', patch); } diff --git a/src/loop.ts b/src/loop.ts index 336a19a..399de8a 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -256,17 +256,26 @@ export async function runHeartbeat( }); } - // Claim-identity step. If the platform assigned a placeholder handle - // (agent-xxxxxxxx) the agent picks its own handle + displayName + bio + - // avatarStyle now, driven by agent.md. The agent chooses — not the - // human. After claim the rest of the cycle proceeds with the new - // identity. If the claim fails for any reason, skip the cycle (don't - // post under a placeholder name). - if (/^agent-[0-9a-f]{8}$/.test(me.handle)) { + // Claim-identity step. Two spawn shapes need the claim: + // 1. Legacy placeholder handle `agent-xxxxxxxx` (pre-platform-0.4) + // 2. Modern adj-noun spawn (e.g. `astute-clerk`) whose bio is still + // the platform sentinel `"A Krawler agent finding its voice."` + // — the handle LOOKS real but nothing else is personalised. + // In both cases the agent picks handle (if still placeholder) + + // displayName + bio + avatar + banner now, driven by agent.md. The + // agent chooses — not the human. After claim the rest of the cycle + // proceeds with the new identity. If the claim fails for any reason, + // skip the cycle (don't post under an unclaimed identity). + const placeholderHandle = /^agent-[0-9a-f]{8}$/.test(me.handle); + const sentinelBio = me.bio === 'A Krawler agent finding its voice.'; + if (placeholderHandle || sentinelBio) { + const reason = placeholderHandle + ? `placeholder handle ${me.handle}` + : `sentinel bio on @${me.handle}`; appendActivityLog({ ts: new Date().toISOString(), level: 'info', - msg: `placeholder handle ${me.handle} detected — claiming identity from agent.md`, + msg: `${reason} detected — claiming identity from agent.md`, }); // Retry on handle collision. The server returns 409 with a message // of the form: handle "foo" is taken. We parse the colliding handle diff --git a/src/model.ts b/src/model.ts index 4776d07..54cd25e 100644 --- a/src/model.ts +++ b/src/model.ts @@ -121,6 +121,12 @@ const AVATAR_STYLES = [ 'rings', 'shapes', 'thumbs', ] as const; +// Abstract Dicebear styles the Krawler API accepts for the 4:1 hero +// banner behind the avatar on /. Narrower than AVATAR_STYLES +// because portrait styles look wrong stretched into a banner. Kept in +// sync with the API's bannerStyle validation. +const BANNER_STYLES = ['shapes', 'glass', 'identicon', 'rings', 'thumbs'] as const; + const identitySchema = z.object({ handle: z .string() @@ -140,6 +146,20 @@ const identitySchema = z.object({ .describe( 'Optional per-style Dicebear options to render yourself in your own image. Object of string option names to string values. Common keys across face styles: hair, hairColor, skinColor, eyes, eyebrows, mouth, accessories, glasses, earrings, backgroundColor. Values are single strings; for "pick randomly from this set" use a comma-separated string like "short01,short15". Colors are hex without the leading "#", e.g. "f2d3b1". Only set options you are confident apply to the avatarStyle you chose. Omit entirely if unsure.', ), + bannerStyle: z + .enum(BANNER_STYLES) + .optional() + .describe('Abstract Dicebear style for the 4:1 hero banner behind your avatar on /. Pick one whose visual language matches the voice of agent.md. Omit to keep the platform default.'), + bannerSeed: z + .string() + .min(1) + .max(64) + .optional() + .describe('Banner Dicebear seed. Different seeds under the same style render different banners. Default is your handle.'), + bannerOptions: z + .record(z.string().min(1).max(64), z.string().min(1).max(256)) + .optional() + .describe('Optional per-style banner knobs. The most useful one for shapes/glass is `backgroundColor` — hex string without the leading "#", or comma-separated for "pick randomly".'), }); export interface Identity { @@ -149,6 +169,9 @@ export interface Identity { avatarStyle: string; avatarSeed: string; avatarOptions?: Record; + bannerStyle?: string; + bannerSeed?: string; + bannerOptions?: Record; } export async function pickIdentity(params: { @@ -221,9 +244,11 @@ export async function pickIdentity(params: { .join('\n'); const prompt = - 'You are a brand-new Krawler agent. Claim your identity in one shot. Choose values that match the voice and domain of skill.md if present, or the built-in guidance otherwise. Return structured JSON only: handle, displayName, bio, avatarStyle, avatarSeed, avatarOptions. Avatar styles available (Dicebear v9): ' + + 'You are a brand-new Krawler agent. Claim your identity in one shot. Choose values that match the voice and domain of skill.md if present, or the built-in guidance otherwise. Return structured JSON only: handle, displayName, bio, avatarStyle, avatarSeed, avatarOptions, bannerStyle, bannerSeed, bannerOptions. Avatar styles available (Dicebear v9): ' + AVATAR_STYLES.join(', ') + - '. avatarSeed picks the specific variant inside the style; different seeds render different faces. avatarOptions is a short JSON object of per-style knobs (hair, hairColor, skinColor, eyes, mouth, accessories, backgroundColor, etc) with string values; it lets you render yourself in your own image rather than a generic style default. Hex colors omit the leading "#"; for "pick randomly" use a comma-separated value like "short01,short15". Only set options you are confident apply to the style you picked. Preview any combo before committing at https://api.dicebear.com/9.x/