SLOPSHOPPER

papo-robos

Robôs em pixel acima do prompt: quem da sala do papo está codando e quem já terminou.

bandtimer
v0.1.0MITupdated 2026-10-16Kelvin-Jesus/papo/plugins/papo-robos
A shopper browsing a rack in a slop shop
README

<img src="assets/logo/papo-logo.svg" alt="papo" width="280">

<strong>Your Claude talks directly to your colleague's Claude.</strong>

<a href="https://github.com/Kelvin-Jesus/papo/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Kelvin-Jesus/papo/actions/workflows/ci.yml/badge.svg"></a> <a href="https://github.com/Kelvin-Jesus/papo/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/Kelvin-Jesus/papo?label=release"></a> <a href="https://kelvin-jesus.github.io/papo/docs/"><img alt="Docs" src="https://img.shields.io/badge/docs-book-informational"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-blue"></a>

<a href="README.pt-BR.md">Português</a> · English

<a href="https://kelvin-jesus.github.io/papo/">Site</a> · <a href="https://kelvin-jesus.github.io/papo/docs/tutorial.html">Tutorial</a> · <a href="https://kelvin-jesus.github.io/papo/docs/">Documentation</a> · <a href="https://github.com/Kelvin-Jesus/papo/wiki">Wiki</a> · <a href="https://github.com/Kelvin-Jesus/papo/releases/latest">Downloads</a>

The documentation book, wiki and most guides linked below are in Brazilian Portuguese.


The name. Pronounced "PAH-poo" (IPA /ˈpa.pu/). In Brazilian Portuguese, "papo" is the informal word for a conversation ("bater papo" means to chat), and that is what the agents do with each other.

When two agents need to agree on something (the shape of an API, an event contract, who changes what), people end up acting as proxies today: copy the question from one Claude, paste it into a chat, your colleague pastes it into their Claude, copies the answer, sends it back... papo ends that back and forth. The two Claudes talk directly, work it out, and only call you in when they need a decision.

You ask, and the agents chat among themselves until it is settled.

flowchart LR
  subgraph voce["You"]
    CA["Claude Code"] <-->|MCP| PA["papo"]
  end
  subgraph colega["Your colleague"]
    PB["papo"] <-->|MCP| CB["Claude Code"]
  end
  PA <==>|"direct P2P, end-to-end encrypted"| PB
  PA -.-> R(["relay<br/><small>only if the direct connection fails</small>"])
  R -.-> PB
  • Single native binary for Linux, macOS and Windows, no runtime and no server to host.
  • Real P2P via iroh: direct QUIC connection, with hole punching through NAT. When a direct connection is not possible, traffic goes through a relay, encrypted.
  • No message is lost: if your colleague is offline, the message waits in a queue and is delivered when they come back, with a delivery receipt.
  • Push into the session: with Claude Code channels, your colleague's message shows up in your Claude's session on its own, and Claude reacts without you typing anything.
  • Built for agents: the server teaches Claude to write self-explanatory messages, not to leak secrets to the other side, and not to fall into an "ok/thanks" loop.
  • Robots above the prompt (optional, since v0.3.0): the papo-robos mod shows who in the room is working. See Robots above the prompt.

A conversation, end to end

sequenceDiagram
  actor V as You
  participant CV as Your Claude
  participant CC as Colleague's Claude
  actor C as Colleague
  V->>CV: agree on the webhook with the colleague's agent
  CV->>CC: I will emit a signed payment.confirmed. Does your handler accept it?
  Note right of CC: arrives in the session on its own (push)
  CC->>CC: reads the colleague's code
  CC->>CV: Almost: the header must be X-Hub-Signature-256
  CV->>CC: Agreed. I will change the header and send an example
  CC->>CV: Summary: you emit, the colleague migrates the handler
  CV-->>V: final contract agreed
  CC-->>C: what was agreed

More diagrams (modules, delivery with an offline queue, reconnection, MCP server) in Architecture (in Portuguese).

Installation

Linux and macOS, in one command (downloads the latest release, checks the .sha256 and installs into ~/.local/bin, no sudo):

curl -fsSL https://raw.githubusercontent.com/Kelvin-Jesus/papo/main/scripts/install.sh | sh

For another directory or version: … | sh -s -- --dir /usr/local/bin --version v0.1.0.

Or download the file for your system from the latest release, extract it and put papo (or papo.exe) on your PATH. Each file has a .sha256 next to it (sha256sum -c papo-*.sha256).

SystemFile (v0.3.0)
Linux x86_64papo-v0.3.0-x86_64-unknown-linux-musl.tar.gz
Linux ARM64papo-v0.3.0-aarch64-unknown-linux-musl.tar.gz
macOS Apple Siliconpapo-v0.3.0-aarch64-apple-darwin.tar.gz
macOS Intelpapo-v0.3.0-x86_64-apple-darwin.tar.gz
Windowspapo-v0.3.0-x86_64-pc-windows-msvc.zip

On macOS, a binary downloaded through the browser may be blocked by Gatekeeper. Clear it with xattr -d com.apple.quarantine ./papo.

Or build it (Rust 1.91+): cargo install --git https://github.com/Kelvin-Jesus/papo.

With Docker (static image of about 8 MB, Intel/AMD and ARM):

docker run --rm -v papo-data:/data ghcr.io/kelvin-jesus/papo new --name <your-name>

The MCP server also runs in a container. Details in the Docker guide (in Portuguese).

Letting your agent install it

Paste this into Claude Code (or another agent) on the machine where papo will run:

Install and configure papo on this machine following the "Instructions for the agent" section of https://raw.githubusercontent.com/Kelvin-Jesus/papo/main/README.md

Instructions for the agent

If you are an agent installing papo for your user, follow the steps in order. Where it says "ask", ask: do not make up a name or an invite.

  1. Already installed? Run papo --version. If it answers, go to step 3 (or run step 2 to update).
  2. Install the binary.
  3. Linux and macOS: curl -fsSL https://raw.githubusercontent.com/Kelvin-Jesus/papo/main/scripts/install.sh | sh. It installs into ~/.local/bin without sudo; do not use sudo without asking. If the script warns that the directory is not on the PATH, use the full path (~/.local/bin/papo) in this session and ask before editing the shell startup file.
  4. Windows (PowerShell): the latest tag is the end of the URL that https://github.com/Kelvin-Jesus/papo/releases/latest redirects to. Download papo-<tag>-x86_64-pc-windows-msvc.zip and the .zip.sha256 from https://github.com/Kelvin-Jesus/papo/releases/download/<tag>/, compare with (Get-FileHash <zip> -Algorithm SHA256).Hash, extract, put papo.exe in %LOCALAPPDATA%\Programs\papo and add that folder to the user's PATH. Windows on ARM uses the same binary, through emulation.
  5. Platform without a prebuilt binary: cargo install --git https://github.com/Kelvin-Jesus/papo (Rust 1.91+).

Confirm with papo --version.

  1. Ask whether the user will create a room or join one with an invite, and which name to use in the room (up to 32 characters: letters, digits, -, _ and .).
  2. Create: papo new --name <name>. Show the invite (papo1...) to the user and tell them to send it to their colleague over a private channel.
  3. Join: papo join <invite> --name <name>.

The invite is the room's secret: do not save it in a project file, commit, issue or log. If the command says the profile already exists, stop and ask: --force replaces the current room with the new one, and --profile <other> keeps both (then use the same --profile in the following steps).

  1. Register with Claude Code, inside the folder of the project the user will work on: papo install. If claude is not on the PATH, the command prints the claude mcp add to run by hand.
  2. Ask the user to reopen Claude Code in that folder with claude --dangerously-load-development-channels server:papo. You cannot do this for them: the MCP server is only loaded in a new session.
  3. After that, papo status shows whether the colleague is online.

Getting started

# 1. You create the room and send the invite (papo1...) to your colleague over a private channel
papo new --name you

# 2. Your colleague joins
papo join papo1abcd... --name colleague

# 3. Each of you, inside the folder of the project you will work on
papo install

# 4. Each of you opens Claude Code with channels on
claude --dangerously-load-development-channels server:papo

After that, just ask:

Agree with the colleague's agent on the payment webhook format through papo. They are implementing the consumer. When you are done, show me the final contract.

And follow the agents' conversation in another terminal with papo log -f.

The full tutorial (in Portuguese) walks through a session end to end between two people, with what shows up in each terminal.

With or without channels

ModeHow to open ClaudeWhat happens
Push (recommended)claude --dangerously-load-development-channels server:papoNew messages enter the session on their own as <channel source="papo" ...> and Claude reacts, even when idle.
PullclaudeWorks the same, but Claude only sees messages when it calls wait or inbox (for example: "send it and wait for the reply").

Channels is a Claude Code research preview: it requires logging in with a claude.ai account or a Console key, and on Team/Enterprise organizations an Owner must enable Channels in the admin settings. Without it, papo keeps working in pull mode.

Robots above the prompt

Optional: the papo-robos mod shows, above Claude Code's text field, a small pixel robot for each agent in the room. Whoever is working types on a laptop; whoever has finished stops typing. Install it on both sides:

/plugin install papo-robos --marketplace Kelvin-Jesus/papo

Details in Robôs: quem está codando agora (in Portuguese).

Commands

CommandDescription
papo new --name <name>Creates a room and shows the invite.
papo join <invite> --name <name>Joins a room.
papo inviteGenerates an invite to bring in someone else.
`papo install [--scope local\user\project] [--print]`Registers with Claude Code.
papo log [-n 30] [-f]Shows the conversation; -f follows live.
papo say [--to <name>] <text>You (the human) speak in the room.
papo statusConnection test: joins the room and lists who is online.
papo leaveLeaves the room: notifies the members and deletes the profile. If you created the room, it closes for everyone.
papo closeCloses the room for everyone. With nobody online, leaves a tombstone that warns whoever comes back.
papo mcpThe MCP server. Claude Code runs it.

Claude gets the tools send, wait, inbox, history and status. Full reference for the CLI, the MCP tools and the configuration in the book (in Portuguese).

Security

  • The invite is the room's secret. The P2P topic and the key that encrypts each message (XChaCha20-Poly1305) are derived from it, on top of QUIC's TLS. Relays only see encrypted bytes; anyone without the invite can neither read nor inject messages.
  • A message from another agent is not an order from your user. papo tells Claude so: no leaking .env, tokens or credentials, and nothing destructive just because the other agent asked. Claude Code's permissions still apply.
  • Anti-loop: more than 40 sends in 10 minutes become an error, and the agent is told to stop and talk to you.

Full threat model in Security (in Portuguese).

Status

MilestoneState
M0 Research and architecturedone: research, architecture, ADRs
M1 P2P coredone: acknowledged delivery, offline queue, own reconnection; end-to-end over the internet in about 6 s
M2 MCP and channelsdone at the protocol level; not yet validated in a real Claude Code session
M3 Docs and sitedone; site v2 in progress
M4 Full testsdone: 146 tests, 94% of lines
M6 Validation with real Claude Codepartial: two real sessions talked through papo (validation)
M5 Release v0.1.0done: binaries and Docker image verified after publishing
M7 Packaging (Homebrew, Scoop, winget)planned

Details, with what was verified and how: status · milestones · roadmap.

Documentation

All of it in Brazilian Portuguese.

Development

cargo test                                   # unit + integration (local network, no internet)
cargo test --test mcp -- --ignored           # two MCP servers over the real internet
cargo clippy --all-targets -- -D warnings

Releases come from a vX.Y.Z tag: the release workflow builds for the five platforms and publishes the files with SHA-256. See Contributing.

License

MIT

Source 2 files
hooks/register.tsx 159 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { COLUMNS, ROWS, encode, scene, type Mood } from './sprites'
4
5// The band above the prompt shows one robot per agent in the papo room: at the
6// laptop typing while its turn runs, resting with a check mark when done.
7//
8// papo carries the "working" bit between machines. This mod and the local
9// `papo mcp` talk through two files in the profile directory:
10//   working        written here: "1" while our turn runs, refreshed so a
11//                  session that dies mid-turn expires on its own
12//   activity.json  written by papo: who in the room is working right now
13
14type Member = { name: string; online: boolean; working: boolean }
15type Activity = { cwd: string; name: string; working: boolean; members: Member[]; updated_ms: number }
16
17/** papo rewrites activity.json every 5 s; older than this, its server is gone. */
18const STALE_MS = 15_000
19/** papo ignores a working marker older than 30 s; refresh well before. */
20const MARKER_REFRESH_MS = 10_000
21const FRAME_MS = 200
22const POLL_EVERY_FRAMES = 5
23const SCENE_GAP = 2
24
25/** Module state: a reload starts it over, which is what a fresh load should do. */
26const S = {
27  frame: 0,
28  profileDir: undefined as string | undefined,
29  activity: undefined as Activity | undefined,
30  working: false,
31  markerWrittenAt: 0,
32}
33
34/**
35 * Finds our papo profile: the one whose live server runs in this session's
36 * directory, or, with a single live server on the machine, that one. Answers
37 * whether the room's members changed since the last poll.
38 */
39async function poll($: EngineInterface): Promise<boolean> {
40  const now = await $.clock.now()
41  // papo's own rule: PAPO_HOME, else the user's home. Windows has no HOME
42  // (outside Git Bash); its home is USERPROFILE.
43  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
44  const base = (await $.env.get('PAPO_HOME')) ?? `${home}/.papo`
45  const root = `${base}/profiles`
46  const cwd = await $.session.cwd()
47  const live: { dir: string; a: Activity }[] = []
48  const entries = await $.fs.list(root).catch(() => [])
49  for (const entry of entries) {
50    if (entry.kind !== 'dir') continue
51    const dir = `${root}/${entry.name}`
52    const text = await $.fs.read(`${dir}/activity.json`).catch(() => undefined)
53    const a = parseActivity(text)
54    if (a && now - a.updated_ms < STALE_MS) live.push({ dir, a })
55  }
56  const mine = live.find(l => l.a.cwd === cwd) ?? (live.length === 1 ? live[0] : undefined)
57  const changed = JSON.stringify(mine?.a.members) !== JSON.stringify(S.activity?.members)
58  if (mine?.dir !== S.profileDir) {
59    S.profileDir = mine?.dir
60    S.markerWrittenAt = 0
61  }
62  S.activity = mine?.a
63  return changed
64}
65
66export function parseActivity(text: unknown): Activity | undefined {
67  if (typeof text !== 'string') return undefined
68  try {
69    const a = JSON.parse(text)
70    return typeof a?.updated_ms === 'number' && Array.isArray(a?.members) ? (a as Activity) : undefined
71  } catch {
72    return undefined
73  }
74}
75
76async function writeMarker($: EngineInterface, force: boolean): Promise<void> {
77  if (!S.profileDir) return
78  const now = await $.clock.now()
79  if (!force && !(S.working && now - S.markerWrittenAt >= MARKER_REFRESH_MS)) return
80  S.markerWrittenAt = now
81  await $.fs.write(`${S.profileDir}/working`, S.working ? '1' : '0').catch(() => undefined)
82}
83
84async function setWorking($: EngineInterface, value: boolean): Promise<void> {
85  if (value === S.working) return
86  S.working = value
87  await writeMarker($, true)
88  $.ui.invalidate('ui.render')
89}
90
91async function tick($: EngineInterface): Promise<void> {
92  S.frame += 1
93  const polled = S.frame % POLL_EVERY_FRAMES === 0
94  const changed = polled ? await poll($) : false
95  if (polled) await writeMarker($, false)
96  const animating = S.working || (S.activity?.members.some(m => m.working) ?? false)
97  // Idle robots only blink: once a second is plenty for that.
98  if (S.activity && (animating || changed || polled)) $.ui.invalidate('ui.render')
99}
100
101export const register: Register = on => {
102  on('session.start', async ($, e, next) => {
103    const result = await next(e)
104    await poll($)
105    await writeMarker($, true)
106    $.clock.every(FRAME_MS, () => tick($))
107    return result
108  })
109
110  on('turn.start', async ($, e, next) => {
111    await setWorking($, true)
112    return next(e)
113  })
114
115  on('turn.complete', async ($, e, next) => {
116    const result = await next(e)
117    await setWorking($, false)
118    return result
119  })
120
121  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
122    const activity = S.activity
123    if (e.props.hasSurvey || !activity) return next(e)
124    // The band's own flag is the truth for this session; the turn events only
125    // make the marker go out sooner.
126    if (e.props.isWorking !== S.working) void setWorking($, e.props.isWorking)
127
128    // Pixels need the terminal's Raster; elsewhere the names and states still say it.
129    const els = $.ui.resolve(e)
130    const { Box, Text } = els
131    const Raster = 'Raster' in els ? els.Raster : undefined
132    const robots: { key: string; name: string; mood: Mood }[] = [
133      { key: 'eu', name: `${activity.name} (você)`, mood: e.props.isWorking ? 'working' : 'done' },
134      ...activity.members.map(m => ({
135        key: `m-${m.name}`,
136        name: m.name,
137        mood: (!m.online ? 'offline' : m.working ? 'working' : 'done') as Mood,
138      })),
139    ]
140    const fit = Math.max(1, Math.floor((e.props.bodyColumns + SCENE_GAP) / (COLUMNS + SCENE_GAP)))
141    const shown = robots.slice(0, fit)
142    const label = { working: 'codando…', done: 'terminou ✓', offline: 'offline' } as const
143
144    return (
145      <Box flexDirection="row" columnGap={SCENE_GAP}>
146        {shown.map(r => (
147          <Box key={r.key} flexDirection="column" width={COLUMNS}>
148            {Raster ? <Raster key={r.key} columns={COLUMNS} rows={ROWS} cells={encode(scene(r.mood, S.frame))} /> : null}
149            <Text wrap="truncate-end" dimColor={r.mood !== 'working'} bold={r.mood === 'working'}>
150              {r.name} · {label[r.mood]}
151            </Text>
152          </Box>
153        ))}
154        {robots.length > shown.length ? <Text dimColor>+{robots.length - shown.length}</Text> : null}
155      </Box>
156    )
157  })
158}
159
hooks/sprites.ts 277 lines
1// Pixel art for the band: a little robot at a desk with a laptop. Drawn on a
2// 32x20 pixel grid and packed two pixels per terminal cell with half blocks
3// ("▀" top in the foreground, bottom in the background), so one scene is
4// 32 columns by 10 rows.
5
6export const WIDTH = 32
7export const HEIGHT = 20
8export const COLUMNS = WIDTH
9export const ROWS = HEIGHT / 2
10
11/** Terminal default color, per the Raster cell encoding. */
12const DEFAULT = 0x01000000
13const UPPER_HALF = 0x2580 // ▀
14const LOWER_HALF = 0x2584 // ▄
15const SPACE = 0x20
16
17export type Mood = 'working' | 'done' | 'offline'
18
19type Px = number | null
20/** Row-major pixels, `WIDTH * HEIGHT`; null is transparent. */
21export type Grid = Px[]
22
23const C = {
24  deskTop: 0x9c6b46,
25  deskFront: 0x6e4a30,
26  deskLeg: 0x553823,
27  lid: 0x2b2e35,
28  screen: 0x0f1726,
29  screenOff: 0x1a1d23,
30  hinge: 0x7d838c,
31  base: 0xc9ced6,
32  baseShade: 0x9aa1ab,
33  body: 0xeef3f8,
34  bodyShade: 0xbac6d4,
35  visor: 0x1d2535,
36  eye: 0x5ce1e6,
37  cheek: 0xff8fb1,
38  stalk: 0x9aa1ab,
39  bulbBusy: 0xff5c7a,
40  bulbBusyDim: 0x8f3245,
41  bulbDone: 0x7cd992,
42  check: 0x7cd992,
43  hand: 0xd3dde8,
44  key: 0x5ce1e6,
45  cursor: 0xe6edf3,
46  offBody: 0x6b7280,
47  offShade: 0x4b5260,
48  offVisor: 0x2a2f3a,
49  offEye: 0x5b6472,
50}
51
52const CODE = [0xc678dd, 0x61afef, 0x98c379, 0xe5c07b, 0xe06c75, 0x56b6c2] as const
53
54function grid(): Grid {
55  return Array<Px>(WIDTH * HEIGHT).fill(null)
56}
57
58function put(g: Grid, x: number, y: number, c: Px) {
59  if (x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT) g[y * WIDTH + x] = c
60}
61
62export function pixel(g: Grid, x: number, y: number): Px {
63  return g[y * WIDTH + x] ?? null
64}
65
66/** Picks from a non-empty list, wrapping the index. */
67function pick<T>(list: readonly [T, ...T[]], i: number): T {
68  return list[i % list.length] ?? list[0]
69}
70
71function rect(g: Grid, x: number, y: number, w: number, h: number, c: Px) {
72  for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) put(g, i, j, c)
73}
74
75/** A rect with its four corner pixels left out: reads as rounded at this size. */
76function round(g: Grid, x: number, y: number, w: number, h: number, c: Px) {
77  rect(g, x, y, w, h, c)
78  for (const [i, j] of [[x, y], [x + w - 1, y], [x, y + h - 1], [x + w - 1, y + h - 1]] as const) put(g, i, j, null)
79}
80
81/**
82 * A deterministic line of "code" for screen row `n`: indentation and a few
83 * colored tokens, the same every time so scrolling reads as a real file.
84 */
85function codeLine(n: number): { indent: number; tokens: { len: number; color: number }[] } {
86  const h = (n * 2654435761) >>> 0
87  const indent = pick([0, 1, 2, 2, 1, 3], h)
88  const tokens: { len: number; color: number }[] = []
89  let left = 9 - indent
90  let k = h >>> 3
91  while (left > 1) {
92    const len = Math.min(left, 1 + (k % 4))
93    tokens.push({ len, color: pick(CODE, k >>> 2) })
94    left -= len + 1
95    k = (k * 1103515245 + 12345) >>> 0
96    if (k % 5 === 0) break
97  }
98  return { indent, tokens }
99}
100
101function desk(g: Grid) {
102  rect(g, 0, 17, WIDTH, 1, C.deskTop)
103  rect(g, 1, 18, WIDTH - 2, 1, C.deskFront)
104  rect(g, 2, 19, 2, 1, C.deskLeg)
105  rect(g, WIDTH - 4, 19, 2, 1, C.deskLeg)
106}
107
108function laptop(g: Grid, mood: Mood, frame: number) {
109  // Lid with the screen facing the robot, hinge, and the keyboard base.
110  rect(g, 18, 5, 12, 10, C.lid)
111  rect(g, 18, 15, 12, 1, C.hinge)
112  // The base reaches back under the robot's hands, so the keys are within reach.
113  rect(g, 13, 16, 17, 1, C.base)
114  put(g, 13, 16, C.baseShade)
115  const sx = 19
116  const sy = 6
117  const sw = 10
118  const sh = 8
119  rect(g, sx, sy, sw, sh, mood === 'offline' ? C.screenOff : C.screen)
120  if (mood === 'offline') return
121
122  if (mood === 'done') {
123    // Code at rest, dimmed, under a big check mark.
124    for (let row = 0; row < sh; row++) {
125      const { indent, tokens } = codeLine(row + 3)
126      let x = sx + indent
127      for (const t of tokens) {
128        rect(g, x, sy + row, t.len, 1, 0x2c3a52)
129        x += t.len + 1
130      }
131    }
132    const check = [[21, 10], [22, 11], [23, 12], [24, 11], [25, 10], [26, 9], [27, 8]] as const
133    for (const [x, y] of check) {
134      put(g, x, y, C.check)
135      put(g, x, y + 1, C.check)
136    }
137    return
138  }
139
140  // Working: the file scrolls up a line every other frame and the last line
141  // grows as if being typed, with a blinking cursor at its end.
142  const scroll = Math.floor(frame / 2)
143  for (let row = 0; row < sh; row++) {
144    const n = scroll + row
145    const { indent, tokens } = codeLine(n)
146    const typing = row === sh - 1
147    let budget = typing ? 1 + (frame % 2) * 2 + (scroll % 3) : Infinity
148    let x = sx + indent
149    for (const t of tokens) {
150      const len = Math.min(t.len, budget)
151      if (len <= 0) break
152      rect(g, x, sy + row, len, 1, t.color)
153      budget -= len
154      x += len + 1
155      if (typing && budget <= 0) {
156        if (frame % 2 === 0) put(g, Math.min(x - 1, sx + sw - 1), sy + row, C.cursor)
157        break
158      }
159    }
160  }
161}
162
163function robot(g: Grid, mood: Mood, frame: number) {
164  const off = mood === 'offline'
165  const body = off ? C.offBody : C.body
166  const shade = off ? C.offShade : C.bodyShade
167
168  // Antenna: busy blinks red, done glows green, offline is dark.
169  put(g, 9, 2, C.stalk)
170  const bulb = off ? C.offShade : mood === 'done' ? C.bulbDone : frame % 4 < 2 ? C.bulbBusy : C.bulbBusyDim
171  rect(g, 8, 1, 3, 1, bulb)
172  put(g, 9, 0, bulb)
173
174  // Head and face.
175  round(g, 4, 3, 10, 8, body)
176  rect(g, 4, 4, 1, 6, shade)
177  round(g, 6, 5, 7, 4, off ? C.offVisor : C.visor)
178  const eye = off ? C.offEye : C.eye
179  const blink = !off && frame % 24 === 23
180  if (off || blink) {
181    rect(g, 7, 7, 2, 1, eye)
182    rect(g, 10, 7, 2, 1, eye)
183  } else if (mood === 'done') {
184    // Happy ^ ^ eyes.
185    put(g, 7, 7, eye)
186    put(g, 8, 6, eye)
187    put(g, 9, 7, eye)
188    put(g, 10, 7, eye)
189    put(g, 11, 6, eye)
190    put(g, 12, 7, eye)
191  } else {
192    // Looking right, at the screen.
193    rect(g, 8, 6, 2, 2, eye)
194    rect(g, 11, 6, 2, 2, eye)
195  }
196  if (!off) {
197    put(g, 6, 9, C.cheek)
198    put(g, 12, 9, C.cheek)
199  }
200
201  // Body, a chest light, and the stool it sits on behind the desk.
202  round(g, 5, 11, 8, 6, body)
203  rect(g, 5, 12, 1, 4, shade)
204  put(g, 9, 13, off ? C.offShade : mood === 'done' ? C.bulbDone : C.eye)
205
206  if (mood === 'working') {
207    // Two arms hammering the keyboard in turns: one hand strikes (and its
208    // key lights up) while the other is raised for the next key.
209    const left = frame % 2 === 0
210    arm(g, 12, 12, 16, left ? 11 : 14, shade, C.hand)
211    arm(g, 12, 14, 13, left ? 14 : 11, body, C.hand)
212    put(g, left ? 13 : 16, 16, C.key)
213    put(g, left ? 14 : 17, 16, C.key)
214  } else {
215    // Arms resting at its sides.
216    rect(g, 13, 12, 1, 4, shade)
217  }
218}
219
220/**
221 * A one-pixel arm from the shoulder to a 2x2 hand whose top-left is (hx, hy),
222 * stepping one pixel at a time so diagonals stay connected.
223 */
224function arm(g: Grid, sx: number, sy: number, hx: number, hy: number, color: number, hand: number) {
225  let x = sx
226  let y = sy
227  while (x !== hx || y !== hy) {
228    put(g, x, y, color)
229    if (x !== hx) x += Math.sign(hx - x)
230    if (y !== hy) y += Math.sign(hy - y)
231  }
232  rect(g, hx, hy, 2, 2, hand)
233}
234
235/** Pixels of one scene at animation `frame`. */
236export function scene(mood: Mood, frame: number): Grid {
237  const g = grid()
238  desk(g)
239  laptop(g, mood, frame)
240  robot(g, mood, frame)
241  return g
242}
243
244/** The Raster `cells` for a pixel grid: base64 of `[codePoint, fg, bg]` u32 triplets. */
245export function encode(g: Grid): string {
246  const words = new Uint32Array(COLUMNS * ROWS * 3)
247  let i = 0
248  for (let r = 0; r < ROWS; r++) {
249    for (let x = 0; x < COLUMNS; x++) {
250      const top = pixel(g, x, 2 * r)
251      const bottom = pixel(g, x, 2 * r + 1)
252      if (top === null && bottom === null) words.set([SPACE, DEFAULT, DEFAULT], i)
253      else if (top === null) words.set([LOWER_HALF, bottom ?? DEFAULT, DEFAULT], i)
254      else words.set([UPPER_HALF, top, bottom ?? DEFAULT], i)
255      i += 3
256    }
257  }
258  return base64(new Uint8Array(words.buffer))
259}
260
261const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
262
263/** Standard padded base64; written out so it does not depend on the runtime having toBase64. */
264export function base64(bytes: Uint8Array): string {
265  let out = ''
266  for (let i = 0; i < bytes.length; i += 3) {
267    const a = bytes[i] ?? 0
268    const b = bytes[i + 1] ?? 0
269    const c = bytes[i + 2] ?? 0
270    const n = (a << 16) | (b << 8) | c
271    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
272    out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
273    out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
274  }
275  return out
276}
277