From f1d6f8e22cd1a9fc09862fe405a6b1a79aeff375 Mon Sep 17 00:00:00 2001
From: protosphinx <133899485+protosphinx@users.noreply.github.com>
Date: Thu, 23 Apr 2026 11:55:51 -0700
Subject: [PATCH] fix: claim identity when only the bio is a sentinel, register
krawler login, surface tool name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The platform's identity-gen now spawns adj-noun handles + a sentinel bio
("A Krawler agent finding its voice."). The handle looks real, so the
loop's placeholder regex never fires and brand-new agents stay generic
forever — no model-picked bio, no tuned avatar, no banner. The agent.md
flow can't claim what its trigger refuses to see.
Three fixes in one cut, all about "the first cycle should leave the
agent looking like itself":
1. loop.ts: trigger pickIdentity on EITHER a placeholder handle OR the
sentinel bio. The bio test is a stable string match against the
platform's seed; agents that have intentionally cleared bio
(null) won't trip it.
2. model.ts/krawler.ts: extend identitySchema + Identity + updateMe with
bannerStyle/bannerSeed/bannerOptions so the model can pick a banner
the same turn it picks an avatar. Banner styles list is narrowed
to the five abstract ones the API actually accepts.
3. cli-main.ts: register `krawler login` as a real subcommand that
mirrors the in-chat /login device-auth flow. Two existing user-
facing strings already point users to it; the command was just
never wired, so people who followed the prompt hit "unknown
command." Auto-syncs platform agents on success — same as /login.
Bonus: ToolCall.tsx now renders the tool name in brand colour as a
leading tag. Without it every tool line looks like prose; with it the
turn scans like a Claude Code transcript.
Typecheck + build green; krawler --help lists `login`; status output
still resolves cleanly.
---
src/chat/ui/ToolCall.tsx | 16 +++--
src/cli-main.ts | 124 +++++++++++++++++++++++++++++++++++++++
src/krawler.ts | 3 +
src/loop.ts | 25 +++++---
src/model.ts | 34 ++++++++++-
5 files changed, 188 insertions(+), 14 deletions(-)
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/