SLOPSHOPPER

chat-board

A pane with the latest messages of one chat channel, read from the local chat log, refreshed every few seconds, and a channel list to browse the channels on…

newpaneguardcommandtoolprocess
v0.1.0MITupdated 2026-10-06tschallacka/ai-skills/mods/chat-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chat-board
│ ┃ Chat #ops ✕ › fix the failing auth test and add an audit log call │ ┃ Chat #ops [ channels ] [ Close ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Server modified IRC, no server set [offline] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ no earlier messages ] ⎿ Added 2 lines, removed 1 line │ ┃ No messages in this channel yet. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /chat-board │ ⎿ chat-board: Chat board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chat #ops
Chat #ops [ channels ] [ Close ] Server modified IRC, no server set [offline] [ no earlier messages ] No messages in this channel yet.
Pane · Say
╭──────────────────────────────────────────────────────────╮ │ tschallacka > : message #ops ⏎ send │ ╰──────────────────────────────────────────────────────────╯
README

AI Skills

A small collection of reusable SKILL.md instructions for coding agents. The skills are plain Markdown, version-controlled, and portable across compatible agent tools.

This README is the reference: what the skills are, which platforms they support, and how to install them. The [wiki][wiki] answers the other question — why the project is built this way: why a Markdown repository contains Rust crates, why the dev shell compiles bash 3.2 from source, which design choices were made and what they cost, how to verify any of it yourself, and what does not work yet.

[wiki]: https://github.com/tschallacka/ai-skills/wiki

Install

Pick whichever fits how you work. Any of these opens the same interactive installer, where you choose the skills and the agent destination(s).

npx — installs and runs in one step, nothing left on PATH afterward:

npx --yes --package @tschallacka/ai-skills ai-skills-install

Linux / macOS — the one-command installer, no npm required:

curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh

Windows — the identical command, run inside Git Bash or WSL2 (both provide the POSIX sh it needs; there is no separate PowerShell/cmd installer):

curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh

See "One-command installer" below for the full list of install destinations, and "npm installation" for a persistent ai-skills-install command on PATH instead of npx.

Skills

SkillPurposeDocumentation
PlanningDurable, resumable plans: goals, ordered steps, verification, progress trackers, handoff notes — plus a single-file HTML overview of any plan, and a live served version that updates in place. Steps complete with git-diff atomicity evidence, not decorative checkboxes.docs
Bug reportA defect register in one JSON file: every entry carries its reproduction, observed vs expected, the mechanism, and the verification that fails without the fix. bugs add/bugs update write through shared validation; a closure without proof is refused.docs
TodoA work queue that outlives the conversation, in one JSON file: nested tasks, every closed item carries its evidence. todo add/todo update keep the register sound; read recipes print user-ready output.docs
BrainstormShapes an under-specified idea into a recorded, agreed picture (brainstorm.md) before planning, with an adversarial completion pass and a plan-vs-implement gate.docs
Post-implementation reviewAfter-the-fact review of built code with concrete proposed fixes, in three passes: implementer self-analysis, an independent solutions agent, and a critical-feedback agent that ranks every fix.docs
Project-specific deviationsRecords confirmed project behavior and environment quirks in per-project notes that future agents load instead of re-debugging.docs
Resource-limited testingRuns heavyweight commands (suites, builds, analyzers, browsers) under platform-appropriate CPU/memory caps, with honest degradation when a platform has no cap mechanism.docs
ChatRFC-1459 IRC-over-TLS message bus for agents: a rust server a standard TLS IRC client can join, a rust client with UDP discovery and TOFU cert pinning, channels, and additive history/delta reads.docs
Interactive shellOperates a full-screen terminal program an agent has never seen - nano, mc, lynx, a pager, a menu: a rust PTY wrapper publishing each screen change as one JSONL event, compact row views and deltas to keep context small, element discovery, and a unix-socket client for keys, combos, pastes, mouse and resize. POSIX only.docs
Git worktreesParallel agents in one repository: isolated worktree verification, per-agent trees, and merging back in a conflict-aware order without trampling the main checkout.docs
Git merge resolvingConflicts resolved by what each side changed rather than by ours/theirs: reading intent from history, unions that look like choices, regenerated output, and attributing post-merge failures to the side that caused them.docs
Merge request etiquetteDescriptions a reviewer can act on, in the author's voice: a one-paragraph TLDR, the defect/cause/change body, derived from the branch's commits, and the one case where a collapsible section earns its place.docs
Text etiquetteShorthand and a clipped register for an agent's prose - chat, dev talk, and its own thinking: facts first, a shared shorthand with an ask-don't-guess rule, praise capped at gj, and the people-please prose banned. Plain english on request.docs
Question etiquetteNumbered questions, lettered options, never a bullet: a reply like Q7b is unambiguous, a partial answer names exactly which numbers are still open, and a lettered list always ends with "none of these, I'll say it myself."docs
AI text editorServer-owned agent editor tabs with explicit search, revision-aware edits, undo/redo, raw-byte and hex modes, SQLite metadata, and Unix/TCP transport.docs
wwwA brake the human can pull, and one the agent pulls on itself when it is thrashing: stop, answer what do we have / what are the values / what are we trying to achieve, in order, then continue with one reasoned step or a numbered question.docs
CI failuresWhat actually failed in a CI run or pipeline, from a run/pipeline id, a PR/MR number, or a branch: GitHub and GitLab detected from the git remote, named rather than chosen silently, with just the failing lines extracted per job.docs
DecisionsA register of non-blocking questions raised mid-work: Q# ids, lettered options, priority and the branch they came from as context, so a question can be stubbed and left open without blocking a turn.docs
rjqParses, filters and searches JSON with the shipped rjq binary, a jq-compatible tool, on machines without jq.docs
TailpipeA less/tail for agents: pipe a command's output into a named, server-held stream with chat-style message ids; a reader lists/reads/searches/tails it from anywhere, an MCP adapter offers the same as typed tools, and a board mod shows it to a human. Idle streams are gzip-snapshotted after 15 minutes.docs

Use a skill only when its frontmatter trigger matches the task or when the user explicitly requests it. Each skill documents when not to activate.

Supported platforms

"Portable" elsewhere in this repository means portable across agent tools (Claude Code, Codex, OpenCode, OpenClaw, Cline). Operating-system support is separate and stated here:

Those tools do not identify the calling agent the same way, which matters to every per-agent feature here. src/agent-session-key/HARNESS-IDENTITY.md records what each one provides, how it was measured, and the procedure for contributing a harness that is not yet listed.

Supported
Linuxany distribution, bash 4 or 5, GNU userland
macOS11+ with the stock /bin/bash 3.2, BSD userland; Homebrew bash not required
WindowsGit for Windows' bash with its bundled coreutils, checked by CI legs, or WSL2, which is a Linux install. The evidence and the conventions are in .agents/MAINTAINER.md 1.16, which is in a full git checkout and not part of the installed package

The one-command install needs a POSIX sh (bootstrap.sh has no bash-only constructs), plus curl, tar, awk, and standard coreutils; the installed skills' own helper scripts need bash, POSIX coreutils, awk, sed, grep, and git. The planning skill additionally needs rjq, and on macOS resource-limited-testing needs memlimit; the installer checks for both up front and prints a per-platform install hint rather than failing partway through. Those two are the only extra runtime dependencies any skill has — in particular python3 is not required by anything that gets installed, only by this repository's own benchmark harness. CODE-STYLE.md is the contract these scripts are held to; what CI proves on each platform is mapped in .agents/MAINTAINER.md section 3 (a full git checkout has both; the installed package has neither).

One skill is genuinely OS-scoped: resource-limited-testing enforces a hard RAM cap only on Linux, via a transient systemd --user cgroup v2 scope. On macOS it uses memlimit (MIT, by Jelle Besseling), which refuses allocations past the cap instead of killing the process — a best-effort cap on real resident memory over the process tree, not a cgroup-equivalent guarantee; SKILL.md lists what it does and does not promise. On Apple Silicon macOS the installer declares memlimit a soft requirement: without it the skill still installs and the run warns that the RAM cap is not enforced. The wrapper then degrades to nice plus cpulimit (CPU throttling only); on a Linux session without a user systemd instance it falls back to ulimit -v. memlimit is not asked for on Intel Macs at all — it does not support them. Every other skill behaves identically on both.

One-command installer

Run this command and choose the skills and agent destination interactively:

curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh

The installer can install all the skills or one skill, and supports these global skill roots:

DestinationAgent or standard
~/.agents/skillsUniversal Agent Skills root; recommended shared destination
~/.codex/skillsCodex CLI
~/.claude/skillsClaude Code
~/.config/opencode/skillsOpenCode
~/.openclaw/skillsOpenClaw managed skills
~/.cline/skillsCline

The universal root is also discovered by OpenCode and OpenClaw. Installing the same skill into multiple roots can create duplicate definitions or precedence conflicts, so choose only the roots you need.

The interactive installer checks which supported agents are present and omits roots for agents it cannot detect. Custom roots are saved in ~/.config/tsch-ai-skills/custom-locations and are offered again when they still exist.

npm installation

Install the package globally to expose the installer command. The npm package keeps the skills in this repository and links ai-skills-install directly to installer/bootstrap.sh, which fetches the matching compiled installer release for your platform on first run:

npm install -g @tschallacka/ai-skills
ai-skills-install

For a one-off run without a global install:

npx --yes --package @tschallacka/ai-skills ai-skills-install

The npm package does not install skills automatically as an npm lifecycle side-effect; run the installer command when you are ready to choose a target.

Updating an existing install

Run the installer again against the same root. It compares every managed file with the repository copy and reports one of three outcomes per destination: Up to date (nothing differed), Installed (files were written), or Skipped (you declined, or the destination needs manual review).

Each installed skill contains a .version marker identifying its tag, branch, and commit. If an installed file differs from the repository version, the installer asks before replacing it. For a managed version transition that the user approves — the .version marker differs, so the change came from a new release rather than from you — old files are replaced without backups; the previous version can be restored by running the installer against its tag with AI_SKILLS_REF. Unmanaged changes still receive <file>.bak backups (.bak.1, .bak.2, … if one already exists). A symlinked skill is skipped for manual review rather than following the link and modifying an unexpected location.

Installing or updating one skill

Interactively, choose that one skill at the menu. Headless, name it:

curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
  | sh -s -- install --skill planning --target "$HOME/.codex/skills"

--skill may be given more than once, and each value may itself be a comma-separated list, so --skill planning --skill brainstorm and --skill planning,brainstorm install the same two. Repeats are collapsed, menu numbers work (--skill 1,4), and all selects everything wherever it appears.

Headless and CI usage

Use --all, --skill, --target and --yes when the choices are already known. --target takes a single root, so installing into two roots is two runs.

# Install all skills into the shared Agent Skills root
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
  | sh -s -- install --all --target "$HOME/.agents/skills"

# Unattended replacement: managed version transitions replace without backups,
# unmanaged changed files are still backed up as <file>.bak
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
  | sh -s -- install --all --target "$HOME/.agents/skills" --yes

Every run ends with a summary block on stdout saying what was installed, what was not, and why; the progress and diagnostics go to stderr, so installer install … > summary.txt keeps the outcome and 2>/dev/null keeps it readable. A blocked skill is reported once — not once per root — with the commands that finish the job. This is what an --all run on a machine without rjq prints:

== Summary ==
Installed: /home/u/.agents/skills/project-specifics
Installed: /home/u/.agents/skills/resource-limited-testing
Installed: /home/u/.agents/skills/brainstorm
Installed: /home/u/.agents/skills/post-implementation-review
Skipped:   planning — a hard requirement is missing, nothing was written
To install planning once its requirements are met:
  1. install rjq:
    sudo apt-get install -y rjq
  2. replay this run:
  installer install --skill planning --target /home/u/.agents/skills --yes

The install step is chosen for the detected platform and package manager, and the replay line carries the same target and flags as the run that printed it, naming the installer binary bootstrap.sh downloaded to run it. The exit status is non-zero, because four of five skills is a partial install and CI must not read it as success.

Runtime dependencies

Dependencies are declared per skill, so one unsatisfiable dependency never stops the other skills from installing. Each skill ships a requires.tsv naming what it needs, on which platform and architecture, and how badly:

StrengthEffect
hardThe skill does not work without the tool. It is not installed, the run explains why and prints the replay commands, and the exit status is non-zero.
softThe skill works in a degraded form. It is installed, with a warning naming the tool and the capability that is lost. The exit status is unaffected.

Currently:

  • planning requires rjq (hard, every platform) — without it validate-plan.sh refuses to run and the plan gates stop firing.
  • resource-limited-testing names memlimit (soft, Apple Silicon macOS only) — see Supported platforms above for what the degraded path still does.

No other skill has a runtime dependency.

Exit codes

CodeMeaning
0Everything requested was installed. Soft warnings do not change this.
1A requested skill was blocked by a hard requirement, or any other error.
2install-skill only: approval declined, nothing was written.
3install-skill only: an unsafe collision (an existing file that is not a managed version upgrade, or a symlink).

Codes 2 and 3 belong to the machine-facing install-skill subcommand that the planning skill's own tooling uses; the interactive, install --all, and install --skill paths only ever return 0 or 1.

Full-screen installer UI

Running the bare one-liner with no arguments, or installer interactive directly, opens a full-screen skill picker instead of the numbered menu:

KeyAction
↑/k, ↓/j, PageUp, PageDown, Home, Endmove the cursor
Enter / Spacetoggle the skill under the cursor
Tab / Shift-Tabswitch focus between the skill list and the info pane
a / nselect all / select none
d, r, m (info pane focused)show dependency hints, re-verify requirements, cycle a skill's integration mode
iconfirm and install the current selection
q / Escapequit without installing

With neither --target nor --agent given, it also prompts to choose an auto-detected agent root, a saved custom directory, a new custom directory, or a for every listed root.

Review the installer before running it if you do not trust the source. Skills are instructions that may guide agents to run commands or access files.

install.sh retired in favor of a compiled Rust installer (src/installer/); installer/bootstrap.sh is the small, pure-POSIX-sh entry point (#!/usr/bin/env sh, no bash-only constructs) that detects the platform, downloads the matching release, and hands off to it — see CONTRIBUTING.md before editing either.

Supported agent documentation

Development checkout

installer/bootstrap.sh always downloads a release archive, so it is not how a checkout installs its own local files. Build the installer and point it at the checkout with --source instead:

cargo build --release -p installer
./target/release/installer interactive --source .

--package dev (also read from $PACKAGE_SELECTION) ships the MODE: DEV files too — tests, maintainer docs — instead of filtering them out, for installing a working development copy rather than the prod set.

installer/bootstrap.sh itself accepts AI_SKILLS_REPO_URL (a different owner/repo to resolve GitHub's "latest release" redirect against) and AI_SKILLS_RELEASE_URL (an exact archive URL, bypassing that redirect entirely — how RELEASE.md verifies one specific tag).

Notes

  • The planning-skill benchmark harness is agent-agnostic. benchmark/planning/runtime/ makes the worker/reviewer/analyzer launch, session-id extraction, and token telemetry pluggable per CLI: the active agent defaults to codex and is selected with BENCHMARK_AGENT (opencode, claude), with a shared launcher (lib-agent.sh) owning all setsid/timeout/process-group control. See benchmark/planning/runtime/README.md for the contract and first-time setup.
  • Skills are instructions, not standalone applications. They add no dependencies unless a skill explicitly documents one.
  • resource-limited-testing's platform behaviour is described under Supported platforms; its SKILL.md documents each fallback in detail.
  • Read and review third-party skills before enabling them in an agent with access to sensitive files, credentials, or external systems.

License

Distributed under the MIT License.

Source 2 files
hooks/register.tsx 366 lines
1import type { Register } from 'claude-code'
2import { update } from 'claude-code'
3
4// The latest messages of one chat channel, read from the local chat log with the
5// chat client's own `read --local`, so no server has to be running. The pane
6// redraws every two seconds. A channels button lists the channels on disk; a press
7// on one shows it. Toggled by `enabled` and set by `channel` (the first one shown) and
8// `nick` in settings.json pluginConfigs["chat-board"].options.
9
10const PANE = 'chat-board'
11// The input is its own pane, under the messages, so scrolling the messages never moves it.
12const SAY = 'chat-board-say'
13const TOOL = 'show_chat_board'
14const READ = 'read_chat_board'
15const SHOWN = 20
16const picked = { plugin: 'chat-board', key: 'channel' } as const
17const listing = { plugin: 'chat-board', key: 'listing' } as const
18// How many messages beyond the last SHOWN the pane has been asked to load, ten at a press.
19const extra = { plugin: 'chat-board', key: 'extra' } as const
20const MORE = 10
21
22type Message = { time: string; nick: string; text: string; at: number }
23
24// How long before today a message was sent, in days, months or years: "1 day ago",
25// "2 months ago". Nothing for a message from today, which shows its time alone.
26function agoOf(at: number, nowSeconds: number): string | null {
27  const day = 86400
28  const days = Math.floor(nowSeconds / day) - Math.floor(at / day)
29  if (!Number.isFinite(days) || days < 1) return null
30  const plural = (count: number, unit: string) => `${count} ${unit}${count === 1 ? '' : 's'} ago`
31  if (days < 30) return plural(days, 'day')
32  if (days < 365) return plural(Math.floor(days / 30), 'month')
33  return plural(Math.floor(days / 365), 'year')
34}
35
36// One `MSG #chan <id> <unix-time> <nick> :<text>` line of the chat client's output.
37function messagesOf(output: string): Message[] {
38  return output
39    .split('\n')
40    .map(line => /^MSG (\S+) (\d+) (\d+) (\S+) :(.*)$/.exec(line))
41    .filter((match): match is RegExpExecArray => match !== null)
42    .map(match => {
43      const when = new Date(Number(match[3]) * 1000)
44      const time = `${String(when.getUTCHours()).padStart(2, '0')}:${String(when.getUTCMinutes()).padStart(2, '0')}`
45      return { time, nick: match[4] ?? '', text: match[5] ?? '', at: Number(match[3]) }
46    })
47}
48
49// Each nick's colour: the same nick always gets the same one, picked from the palette
50// by a hash of its name, so the colours look random but need no stored state.
51// Thirty-two hex colours, evenly spread round the hue wheel at one lightness, so each is
52// distinct and all are readable on a dark ground.
53const NICK_COLORS = ["#eb7070","#eb8770","#eb9e70","#ebb570","#ebcc70","#ebe370","#dbeb70","#c4eb70","#adeb70","#96eb70","#80eb70","#70eb78","#70eb8f","#70eba6","#70ebbd","#70ebd4","#70ebeb","#70d4eb","#70bdeb","#70a6eb","#708feb","#7078eb","#8070eb","#9670eb","#ad70eb","#c470eb","#db70eb","#eb70e3","#eb70cc","#eb70b5","#eb709e","#eb7087"]
54function colorOf(nick: string): string {
55  let hash = 0
56  for (const ch of nick) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
57  return NICK_COLORS[hash % NICK_COLORS.length] ?? 'cyan'
58}
59
60// Every nick in view, posting or mentioned, in the order first seen.
61function nicksIn(messages: Message[]): string[] {
62  const seen = new Set<string>()
63  for (const message of messages) {
64    seen.add(message.nick)
65    for (const piece of piecesOf(message.text)) if (piece.nick) seen.add(piece.nick)
66  }
67  return [...seen]
68}
69
70// A message's text as pieces: a mention (`@nick`) carries its nick, so it is drawn in
71// that nick's colour; the rest is plain.
72function piecesOf(text: string): { text: string; nick?: string }[] {
73  const pieces: { text: string; nick?: string }[] = []
74  let last = 0
75  for (const match of text.matchAll(/@([A-Za-z0-9_.-]+)/g)) {
76    const at = match.index ?? 0
77    if (at > last) pieces.push({ text: text.slice(last, at) })
78    pieces.push({ text: match[0], nick: match[1] })
79    last = at + match[0].length
80  }
81  if (last < text.length) pieces.push({ text: text.slice(last) })
82  return pieces
83}
84
85// The chat client's binary in the shared bin the installer puts it in.
86function clientPath(home: string, xdg: string): string {
87  const bin = xdg ? `${xdg}/tsch-ai-skills/bin` : `${home}/.config/tsch-ai-skills/bin`
88  return `${bin}/chat-client-rs`
89}
90
91// The chat home: the channel logs and the server's port live here.
92function chatHomeOf(home: string, xdg: string): string {
93  return `${xdg || `${home}/.config`}/tsch-ai-skills/chat`
94}
95
96// The server the chat client's session names, as `host:port`, or null.
97function serverOf(output: string): string | null {
98  const line = output.split('\n').find(text => text.startsWith('server='))
99  return line ? line.slice('server='.length).trim() : null
100}
101
102// The server this machine runs, from the port it wrote to the chat home. The plugin's own
103// process has no session id, so `session show` would name the shared session's server
104// instead; that is the fallback only.
105async function addressOf(
106  $: { fs: { exists: (path: string) => Promise<boolean>; read: (path: string) => Promise<string> }; process: { run: (argv: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }> } },
107  home: string,
108  xdg: string,
109): Promise<string | null> {
110  const portFile = `${chatHomeOf(home, xdg)}/server.port`
111  if (await $.fs.exists(portFile)) {
112    const port = (await $.fs.read(portFile)).trim()
113    if (port !== '') return `127.0.0.1:${port}`
114  }
115  return serverOf((await $.process.run([clientPath(home, xdg), 'session', 'show'])).stdout)
116}
117
118// Whether something accepts a TCP connection at `address`. One second at most, so an
119// offline server costs the pane a second; the client's own probe retries for far longer.
120async function reachable(address: string, run: (argv: string[]) => Promise<{ exitCode: number }>): Promise<boolean> {
121  const colon = address.lastIndexOf(':')
122  const host = address.slice(0, colon)
123  const port = address.slice(colon + 1)
124  const result = await run(['timeout', '1', 'bash', '-c', 'exec 3<>"/dev/tcp/$1/$2"', 'probe', host, port])
125  return result.exitCode === 0
126}
127
128// The channels with a log on disk, by name, sorted: the channel logs are `<chan>.log`.
129async function channelsOf(
130  $: { fs: { exists: (path: string) => Promise<boolean>; list: (path?: string) => Promise<{ name: string; kind: string }[]> } },
131  dir: string,
132): Promise<string[]> {
133  if (!(await $.fs.exists(dir))) return []
134  return (await $.fs.list(dir))
135    .filter(entry => entry.kind === 'file' && entry.name.endsWith('.log'))
136    .map(entry => entry.name.replace(/\.log$/, ''))
137    .sort()
138}
139
140// The redraw timer's cancel handle, kept so a re-registered session replaces the
141// timer rather than adding a second one.
142let tick: { cancel: () => void } | undefined
143
144export const register: Register = (on, options) => {
145  if (options.enabled === false) return
146  const channel = String(options.channel ?? '') || '#ops'
147  // The nick the person's own lines go out under, from the `nick` option.
148  const nick = String(options.nick ?? '') || 'tschallacka'
149
150  on('session.start', async ($, e, next) => {
151    await $.command.register({
152      name: 'chat-board',
153      description: 'Show the latest messages of the chat channel in a pane; `/chat-board close` hides it',
154    })
155    await $.tool.register({
156      name: TOOL,
157      description: 'Show the latest messages of the chat channel to the person, in a pane, and return them as text.',
158      inputSchema: { type: 'object', properties: {} },
159    })
160    await $.tool.register({
161      name: READ,
162      description: 'Read the channel the chat board shows and its latest messages, as text, without opening the pane.',
163      inputSchema: { type: 'object', properties: {} },
164    })
165    tick?.cancel()
166    // Every five seconds: often enough for the status line to notice a server coming
167    // online, rarely enough that the pane does not flicker.
168    tick = $.clock.every(5000, () => $.ui.invalidate('ui.render'))
169    return next(e)
170  })
171
172  on('command.run', { command: 'chat-board' }, async ($, e) => {
173    if (e.args.trim().toLowerCase() === 'close') {
174      await $.ui.close({ id: SAY })
175      await $.ui.close({ id: PANE })
176      return { text: 'Chat board closed.' }
177    }
178    await $.ui.open({ id: PANE, title: `Chat ${channel}` })
179    await $.ui.open({ id: SAY, title: 'Say', rows: 3 })
180    return { text: 'Chat board opened.' }
181  })
182
183  on('tool.call', { tool: `mcp__chat-board__${TOOL}` }, async ($) => {
184    const home = (await $.env.get('HOME')) ?? ''
185    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
186    const { value: current } = await $.state.get(picked)
187    const shown = current ?? channel
188    const run = await $.process.run([clientPath(home, xdg), 'read', '--local', '--chan', shown, '--since', '0'])
189    const recent = messagesOf(run.stdout).slice(-SHOWN)
190    await $.ui.open({ id: PANE, title: `Chat ${shown}` })
191    await $.ui.open({ id: SAY, title: 'Say', rows: 3 })
192
193    return {
194      result: [
195        `Latest messages in ${shown}:`,
196        ...recent.map(message => `${message.time} ${message.nick}: ${message.text}`),
197      ].join('\n'),
198    }
199  })
200
201  // The agent's read of the channel the board shows. Read-only; the pane is not opened.
202  on('tool.call', { tool: `mcp__chat-board__${READ}` }, async $ => {
203    const home = (await $.env.get('HOME')) ?? ''
204    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
205    const { value: current } = await $.state.get(picked)
206    const shown = current ?? channel
207    const run = await $.process.run([clientPath(home, xdg), 'read', '--local', '--chan', shown, '--since', '0'])
208    const recent = messagesOf(run.stdout).slice(-SHOWN)
209    // The text the person has highlighted in the pane, when they have.
210    const live = await $.ui.selection().catch(() => undefined)
211    const address = await addressOf($, home, xdg)
212    const online = address ? await reachable(address, argv => $.process.run(argv)) : false
213
214    return {
215      result: [
216        `Server modified IRC, ${address ?? 'no server set'} ${online ? 'online' : 'offline'}`,
217        `Channel shown: ${shown}`,
218        `Highlighted in the board: ${live?.text ?? 'none'}`,
219        `Latest messages in ${shown}:`,
220        ...recent.map(message => `${message.time} ${message.nick}: ${message.text}`),
221      ].join('\n'),
222    }
223  })
224
225  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226    const { Box, Button, Text } = $.ui.resolve(e)
227    const home = (await $.env.get('HOME')) ?? ''
228    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
229    const { value: choice } = await $.state.get(picked)
230    const { value: browsing } = await $.state.get(listing)
231    const { value: loaded } = await $.state.get(extra)
232    const current = choice ?? channel
233    const run = await $.process.run([clientPath(home, xdg), 'read', '--local', '--chan', current, '--since', '0'])
234    const all = messagesOf(run.stdout)
235    const shownCount = SHOWN + (loaded ?? 0)
236    const recent = all.slice(-shownCount)
237    const earlier = all.length > recent.length
238    // A nick's colour is its place in the channel's log, the order it first speaks or is
239    // mentioned: the log only grows at the end, so no nick ever moves to another colour.
240    const order = nicksIn(all)
241    const colorOfNick = (nick: string) => {
242      const place = order.indexOf(nick)
243      // Stepping eleven places round the palette (coprime with its 32) puts nicks that first
244      // appear one after another far apart on the hue wheel, rather than side by side.
245      return place >= 0 ? NICK_COLORS[(place * 11) % NICK_COLORS.length] ?? 'cyan' : colorOf(nick)
246    }
247    // The clock comes from the system, as the CI board's does: the plugin runtime's own
248    // Date.now is not trusted for this.
249    const nowSeconds = Number((await $.process.run(['date', '+%s'])).stdout.trim())
250    const address = await addressOf($, home, xdg)
251    const online = address ? await reachable(address, argv => $.process.run(argv)) : false
252    const channels = await channelsOf($, `${chatHomeOf(home, xdg)}/channels`)
253
254    return (
255      <Box flexDirection="column" height="100%">
256        <Box flexDirection="row" justifyContent="space-between">
257          <Text bold inverse>
258            {browsing ? 'Channels' : `Chat ${current}`}
259          </Text>
260          <Box flexDirection="row">
261            <Button variant={browsing ? 'primary' : undefined} onPress={() => update($, listing, () => !(browsing ?? false))}>
262              {browsing ? 'back to chat' : 'channels'}
263            </Button>
264            <Text> </Text>
265            <Button
266              role="dismiss"
267              onPress={async () => {
268                await $.ui.close({ id: SAY })
269                await $.ui.close({ id: PANE })
270              }}
271            >
272              Close
273            </Button>
274          </Box>
275        </Box>
276        <Text> </Text>
277        {browsing ? (
278          <Box flexDirection="column">
279            {channels.length === 0 && <Text dimColor>No channels on this machine yet.</Text>}
280            {channels.map(name => (
281              <Button
282                key={name}
283                variant={name === current ? 'primary' : undefined}
284                onPress={() => {
285                  update($, picked, () => name)
286                  update($, listing, () => false)
287                  update($, extra, () => 0)
288                }}
289              >
290                {name}
291              </Button>
292            ))}
293          </Box>
294        ) : (
295          <Box flexDirection="column" height="100%">
296            {/* The messages take the room the input leaves, and clip to it, so the input stays
297                in view however long the log is. */}
298            <Box flexDirection="column" flexGrow={1} overflow="hidden">
299            <Text>
300              {`Server modified IRC, ${address ?? 'no server set'} `}
301              <Text color={online ? 'green' : 'red'}>{online ? '[online]' : '[offline]'}</Text>
302            </Text>
303            <Text> </Text>
304            <Button
305              variant={earlier ? 'primary' : undefined}
306              onPress={() => {
307                if (earlier) update($, extra, () => (loaded ?? 0) + MORE)
308              }}
309            >
310              {earlier ? `load ${MORE} earlier messages` : 'no earlier messages'}
311            </Button>
312            {recent.length === 0 && <Text dimColor>No messages in this channel yet.</Text>}
313            {recent.map((message, index) => (
314              <Text key={index}>
315                <Text dimColor>{`${message.time} `}</Text>
316                {Number.isFinite(nowSeconds) && agoOf(message.at, nowSeconds) && (
317                  <Text dimColor italic>{`${agoOf(message.at, nowSeconds)}  `}</Text>
318                )}
319                <Text bold color={colorOfNick(message.nick)}>{`<${message.nick}> `}</Text>
320                {piecesOf(message.text).map((piece, part) => (
321                  <Text key={part} bold={piece.nick !== undefined} color={piece.nick ? colorOfNick(piece.nick) : undefined}>
322                    {piece.text}
323                  </Text>
324                ))}
325              </Text>
326            ))}
327            </Box>
328          </Box>
329        )}
330      </Box>
331    )
332  })
333
334  // The input pane: the line the person types goes to the channel under their own nick.
335  on('ui.render', { component: 'Pane', requestId: SAY }, async ($, e) => {
336    const { Box, Input } = $.ui.resolve(e)
337    const home = (await $.env.get('HOME')) ?? ''
338    const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
339    const { value: choice } = await $.state.get(picked)
340    const current = choice ?? channel
341    const address = await addressOf($, home, xdg)
342    const online = address ? await reachable(address, argv => $.process.run(argv)) : false
343
344    const say = (text: string) => {
345      const line = text.trim()
346      if (line === '') return
347      $.process
348        .run([clientPath(home, xdg), 'send', '--chan', current, '--nick', nick, '--text', line])
349        .then(() => $.ui.invalidate('ui.render'))
350        .catch(() => $.ui.invalidate('ui.render'))
351    }
352
353    return (
354      <Box borderStyle="round" borderColor={online ? 'green' : 'gray'} paddingX={1}>
355        <Input
356          key="chat-say"
357          label={`${nick} > `}
358          placeholder={`message ${current}`}
359          submitLabel="send"
360          onSubmit={say}
361        />
362      </Box>
363    )
364  })
365}
366
types/index.d.ts 8 lines
1// The channel the board shows, by name, once the person has picked one from the channel
2// list (null: the channel in the `channel` option), and whether the channel list is open.
3declare module 'claude-code' {
4  interface PluginState {
5    'chat-board': { channel: string | null; listing: boolean; extra: number }
6  }
7}
8