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/