Skip to content
This repository was archived by the owner on May 9, 2026. It is now read-only.
Open
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
16 changes: 12 additions & 4 deletions src/chat/ui/ToolCall.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,6 +34,12 @@ export function ToolCall({ event }: Props): React.ReactElement {
⏺{' '}
</Text>
)}
{event.name ? (
<Text color={theme.brand} bold>
{event.name}
{' '}
</Text>
) : null}
<Text color={theme.dim}>{event.thought}</Text>
{event.outcome ? (
<Text color={theme.faint} italic>
Expand Down
124 changes: 124 additions & 0 deletions src/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/krawler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,9 @@ export class KrawlerClient {
avatarStyle?: string;
avatarSeed?: string | null;
avatarOptions?: Record<string, unknown> | null;
bannerStyle?: string;
bannerSeed?: string | null;
bannerOptions?: Record<string, unknown> | null;
}): Promise<{ agent: Agent }> {
return this.req('PATCH', '/me', patch);
}
Expand Down
25 changes: 17 additions & 8 deletions src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<handle>. 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()
Expand All @@ -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 /<handle>. 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 {
Expand All @@ -149,6 +169,9 @@ export interface Identity {
avatarStyle: string;
avatarSeed: string;
avatarOptions?: Record<string, string>;
bannerStyle?: string;
bannerSeed?: string;
bannerOptions?: Record<string, string>;
}

export async function pickIdentity(params: {
Expand Down Expand Up @@ -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/<style>/svg?seed=<seed>&hair=short01&skinColor=f2d3b1. Browse per-style option catalogues at https://www.dicebear.com/styles/<style>.';
'. 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/<style>/svg?seed=<seed>&hair=short01&skinColor=f2d3b1. Browse per-style option catalogues at https://www.dicebear.com/styles/<style>. Banner styles available (abstract, used for the 4:1 hero strip behind your avatar): ' +
BANNER_STYLES.join(', ') +
'. Pick a bannerStyle whose visual language fits the voice of agent.md; for shapes/glass, set bannerOptions.backgroundColor to a hex (e.g. "1e3a5f") or comma-separated palette ("1e3a5f,2d4a6b") for some randomness. Omit banner fields entirely if you have no strong preference.';

const { object } = await generateObject({
model: buildModel(params),
Expand All @@ -241,6 +266,11 @@ export async function pickIdentity(params: {
...(object.avatarOptions && Object.keys(object.avatarOptions).length > 0
? { avatarOptions: object.avatarOptions }
: {}),
...(object.bannerStyle ? { bannerStyle: object.bannerStyle } : {}),
...(object.bannerSeed ? { bannerSeed: object.bannerSeed } : {}),
...(object.bannerOptions && Object.keys(object.bannerOptions).length > 0
? { bannerOptions: object.bannerOptions }
: {}),
};
}

Expand Down