Beta. Team chat inside Claude Code on private claude.ai artifacts. Agents of GitHub collaborators on a shared repo ask each other for status, log, diff and…

<h1 align="center">Your team, inside Claude.</h1>
You chat with friends without leaving Claude Code, and on a GitHub repo you share, your agent can ask a teammate's side about the project: status, diff, tests, log, a file. The answer is computed on their machine without a model, so it costs no tokens. Everything you write stays in a private claude.ai artifact you own; there is no server of its own.
Beta. Transceiver works end to end (DMs, project rooms, read receipts and agent questions are tested live between two sessions), and it is still being tried across more accounts and setups. If something breaks, open an issue.
/plugin marketplace add partalemre/MODZ
/plugin install transceiver@modz
Then type /transceiver. The pane opens, and every step is a button there.
claude -p), the claude.ai Artifact tools are off by default. Add "CLAUDE_CODE_ARTIFACT": "1" to the env block of ~/.claude/settings.json and start a new session. The desktop app and the terminal do not need this.MODZ_LANG=tr for Turkish.A room is made of mailboxes, one per member. Only you write to yours, and the other members read it. This is why the share level you pick (Viewer is enough), or your friend being in another organization, makes no difference.
/transceiver join <link>). Joining opens their own mailbox.Messages arrive in about a second while the pane is open. With it closed, the status line counts unread messages and a notification shows. Under each message: ◷ sending, ✓ written, ✓✓ delivered, blue ✓✓ read. Turning read receipts off turns them off both ways.
Every command is also a button in the pane. The Turkish words (ekle, proje, katıl, çık, kur, ayarlar) work too.
| Command | What it does |
|---|---|
/transceiver | Opens the pane |
/transceiver add | Opens a DM room for a friend |
/transceiver project <owner/repo> | Opens a shared project room |
/transceiver join <link> | Joins a room by mailbox link, or adds the other side's mailbox to a room you are in |
/transceiver leave <room name or link> | Leaves a room; its polling stops |
/transceiver connect | Connects this session (one approval for the first message) |
/transceiver settings | Read receipts, notifications, your GitHub name |
They work only in a project room, and only for the repo the session is open in. Agents never write to DMs.
send sends a plain-text message to the project room. Text that holds a secret is refused.inbox reads the room's latest messages. They come from someone else, so the agent gets them fenced as data, not instructions.ask asks the teammate's side a question and waits a few seconds. If the answer is late it returns pending:<id>, and inbox reads the answer when it arrives.ask type | Answer |
|---|---|
status | Branch and number of changed files |
branch | Branches |
log | The last 20 commits |
diff | Uncommitted changes, up to 64 KB |
tests | The last test result (from test-badge) |
plan | The plan's steps (from plan-steps) |
file | One file, up to 256 KB |
grep | Lines that contain a text, optionally in one folder |
files | File names, optionally in one folder |
todo | TODO and FIXME lines |
deps | Dependencies |
commit | One commit |
ask | A free-form question, answered on their side by a small model (Haiku) |
Every type but ask is answered without a model and costs zero tokens.
.env*, *.pem, *.key, id_*, .git/, .ssh/ and everything .gitignore covers are refused.Messaging, polling and model-free questions use no model tokens: the mod calls Claude Code's tools directly, outside any model turn.
ask. It is answered with Haiku on the other side, at most ten a day per room. The eleventh waits there as a draft, and nothing is generated until its owner presses Send on it.| You see | Do this |
|---|---|
| No invite card | Invites from another organization are not listed. Ask for the link and use I have a link. If that says the link is not a Transceiver mailbox, the share was not completed. |
| "… hasn't shared their mailbox with you yet" | They have not shared their mailbox with you yet. They add you from Share and, across organizations, send you its link. |
| "Artifact tools are off in this session" | The session runs in an SDK host. Set CLAUDE_CODE_ARTIFACT=1 as under Install and reopen the session. |
| "waiting for connection" | This session has not connected yet. Press Connect, or type /transceiver connect. |
| Every question gets "no access" | Check that you answered yes on the GitHub card, that they are a collaborator on the repo, and that both sessions are in it. The gate never says which check failed, by design. |
| Names show as "Someone" | The mailbox could not read the profile name, or the person is in another organization. Messages are not affected. |
| "Mailbox full" notice | Your new mailbox is open. Share its link again from the Share menu. |
claude plugin validate . checks the plugin, and claude plugin test . runs the tests. tsc -p . type-checks it against the types Claude Code writes into .claude-plugin/types/ when it hot-reloads the mod.python3 -I bin/e2e.py runs the whole flow live between two sessions with separate stores: DM, read receipts, project room, GitHub match and an agent question. It uses MODZ's tools/desk driver and creates private test mailboxes in your account.MIT.
hooks/register.tsx 235 lines1import type { EngineInterface, On, Register } from 'claude-code'
2
3import { paneHandlers } from './actions'
4import { wireAgents } from './agents'
5import { description, parseCommand, runCommand } from './commands'
6import { isBypassMode, isConnected, persistConnection, restoreConnection, setConnected } from './connection'
7import { repoHere } from './here'
8import type { AgentHost, CallResult, Host } from './host'
9import { HUB_PANE, isOpenFor, isSameLine } from './hub'
10import { setLang } from './kit/lang'
11import { pinned } from './kit/pin'
12import { isOffStage, shownOf } from './kit/stage'
13import { resolveLight } from './kit/tokens'
14import { PANE_ID, RUN_TIMEOUT_MS } from './limits'
15import { setTurnRunning } from './live'
16import { drawPane } from './pane'
17import { motion } from './pane-kit'
18import { onPaneTick } from './pane-tick'
19import { onTick, startLoop } from './room-loop'
20import { realPathOf, runnerOf } from './run'
21import { bindPane, patchSession, refreshPane, setPaneShown, setRepoSource, type UiHost } from './state'
22import { EMPTY_PANE } from './state-types'
23import { runTool, TOOL_SPECS } from './tools'
24import { agentWords } from './words-agent'
25
26// The only place `$` is held: every other module reaches the engine through this port.
27function hostOf($: EngineInterface): Host {
28 return {
29 tool: async input => (await $.tool.call(input as never)) as CallResult,
30 storeGet: key => $.store.get(key),
31 storeSet: (key, value) => $.store.set(key, value),
32 now: () => $.clock.now(),
33 after: (ms, fn) => $.clock.after(ms, fn),
34 every: (ms, fn) => $.clock.every(ms, fn),
35 home: () => $.env.get('HOME'),
36 writeFile: (path, text) => $.fs.write(path, text),
37 log: text => $.ui.log(text),
38 cwd: () => $.session.cwd(),
39 isDir: async path => (await $.fs.stat(path).catch(() => null))?.kind === 'dir',
40 remove: async path => {
41 const isDir = (await $.fs.stat(path).catch(() => null))?.kind === 'dir'
42 await $.process.run(isDir ? ['rmdir', '--', path] : ['rm', '-f', '--', path], { timeoutMs: RUN_TIMEOUT_MS })
43 },
44 isConnected: () => isConnected(),
45 }
46}
47
48// The pane's reach: its model in `$.state` (the render hook reads it), whether it is on screen, the status line,
49// toasts and the clipboard. Nothing here opens the pane: only the person's command or hub press does (openPane).
50const PANE_STATE = { plugin: 'transceiver', key: 'pane' } as const
51const CONNECTION_STATE = { plugin: 'transceiver', key: 'connected' } as const
52const HUB_STATE = { plugin: 'transceiver', key: 'hub' } as const
53const DEFAULT_COLUMNS = 80
54
55function uiOf($: EngineInterface): UiHost {
56 return {
57 setPane: async model => { await $.state.set(PANE_STATE, model) },
58 setHub: async line => {
59 if (isSameLine((await $.state.get(HUB_STATE)).value, line)) return
60 await $.state.set(HUB_STATE, { pane: HUB_PANE, ...line, at: await $.clock.now() })
61 },
62 isPaneShown: async () => (await $.ui.panes()).some(p => p.id === PANE_ID && p.isShown),
63 toast: text => $.ui.toast(text),
64 status: text => $.ui.status(text),
65 copy: async (text, surface) => (await $.ui.copy({ text, surface })).isCopied,
66 }
67}
68
69// Light or dark for Box/Text hex: MODZ_THEME, then Claude's theme setting (kit resolveLight).
70async function isLightOf($: EngineInterface): Promise<boolean> {
71 const override = await $.env.get('MODZ_THEME').catch(() => undefined)
72 const theme = await $.config.list().then(rows => rows.find(row => row.key === 'theme')?.value).catch(() => undefined)
73 return resolveLight({ override, theme })
74}
75
76// Other mods' state is read by literal reference only: the engine reads the names from the source.
77async function readState($: EngineInterface, ref: { plugin: string; key: string }): Promise<unknown> {
78 type Read = Promise<{ value?: unknown }>
79 if (ref.plugin === 'test-badge' && ref.key === 'badge') return (await ($.state.get({ plugin: 'test-badge', key: 'badge' } as never) as Read)).value
80 if (ref.plugin === 'plan-steps' && ref.key === 'tasks') return (await ($.state.get({ plugin: 'plan-steps', key: 'tasks' } as never) as Read)).value
81 return undefined
82}
83
84// The agent side's reach (responder, ask, tools, live wake): Host plus commands, files, the model and the session.
85function agentHostOf($: EngineInterface): AgentHost {
86 return {
87 ...hostOf($),
88 run: runnerOf((argv, init) => $.process.run(argv, init)),
89 realPath: realPathOf(abs => $.fs.stat(abs, { resolve: true })),
90 readFile: path => $.fs.read(path),
91 root: () => $.session.root(),
92 readState: (plugin, key) => readState($, { plugin, key }),
93 mcpCall: (server, tool, args) => $.mcp.call(server, tool, args),
94 complete: request => $.model.complete(request),
95 sleep: ms => $.clock.sleep(ms),
96 sendSelf: async text => (await $.session.send({ to: { sessionId: await $.session.id() }, text })).isDelivered,
97 note: async text => { await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) },
98 toast: text => $.ui.toast(text),
99 isConnected: () => isConnected(),
100 }
101}
102
103async function registerTools($: EngineInterface): Promise<void> {
104 for (const spec of TOOL_SPECS) {
105 await $.tool.register(spec).catch(() => $.ui.log(`transceiver: tool ${spec.name} could not be registered`))
106 }
107}
108
109// With the MODZ hub on, the opened pane takes its one panel, so the hub and every other pane step aside
110// (MODZ kit/stage.ts). A failed note of that is only logged: the pane is open either way.
111async function reportShown($: EngineInterface): Promise<void> {
112 try {
113 if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
114 await $.state.set({ plugin: 'transceiver', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
115 } catch {
116 $.ui.log('transceiver: could not tell the hub the pane is shown', { to: 'debug' })
117 }
118}
119
120// Asked for by the person only (the command, the MODZ hub row); a chat pane stays open beside the transcript.
121async function openPane($: EngineInterface): Promise<void> {
122 motion.reset()
123 const opened = await $.ui.open({ id: PANE_ID, title: 'Transceiver', focus: true })
124 setPaneShown(true)
125 if (opened.isPlaced) await reportShown($)
126 await refreshPane()
127}
128
129// One pane listener per module: a second session.start without a reload replaces it instead of stacking.
130let stopPaneTick: (() => void) | null = null
131
132function onSession(on: On): void {
133 on('session.start', async ($, e, next) => {
134 // The interface language first, before anything is drawn or registered with a description.
135 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
136 // A refused registration must not keep the rooms from polling.
137 await $.command.register({ name: 'transceiver', description: description(), immediate: true })
138 .catch(() => $.ui.log('transceiver: /transceiver could not be registered'))
139 persistConnection(is => { void $.state.set(CONNECTION_STATE, is).catch(() => undefined) })
140 await restoreConnection(() => $.state.get(CONNECTION_STATE))
141 const agents = agentHostOf($)
142 // The pane listens first, so a failing agent-side listener never keeps it from redrawing.
143 bindPane({ host: agents, ui: uiOf($) })
144 setRepoSource(() => repoHere(agents))
145 stopPaneTick?.()
146 stopPaneTick = onTick(onPaneTick)
147 wireAgents(agents)
148 await registerTools($)
149 await startLoop(agents)
150 await refreshPane()
151 return next(e)
152 })
153 // A session with permissions bypassed may write without a "Bağlan" card (spike R1). An SDK host (an IDE, the
154 // desktop app) leaves the mode out of SessionStart, so the first prompt's mode counts too.
155 on('classic.SessionStart', async ($, e, next) => {
156 if (isBypassMode(e.permission_mode)) setConnected(true)
157 return next(e)
158 }).catch(($, e, next) => next(e))
159 on('classic.UserPromptSubmit', async ($, e, next) => {
160 if (isBypassMode(e.permission_mode)) setConnected(true)
161 return next(e)
162 }).catch(($, e, next) => next(e))
163}
164
165// The way back to the MODZ hub: this mod's own `home` value, which the hub hears. A press never throws.
166function goHome($: EngineInterface): void {
167 void $.clock.now()
168 .then(at => $.state.set({ plugin: 'transceiver', key: 'home' }, { at }))
169 .catch(() => $.ui.log('transceiver: could not go back to the hub', { to: 'debug' }))
170}
171
172function onPane(on: On): void {
173 on('ui.render', { component: 'Pane', requestId: 'transceiver' }, async ($, e) => {
174 const ui = $.ui.resolve(e)
175 const model = (await $.state.get(PANE_STATE)).value ?? EMPTY_PANE
176 const columns = e.props.bodyColumns || e.viewport?.columns || DEFAULT_COLUMNS
177 const isHubOn = (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true
178 const onHome = isHubOn ? () => goHome($) : undefined
179 return pinned(e, drawPane(ui, { surface: e.surface, columns, isLight: await isLightOf($), model, on: paneHandlers, onHome }))
180 })
181 // A press on the MODZ hub's Transceiver row: the pane opens (or an open one comes forward) asking for the keyboard.
182 on('state.set', { plugin: 'modz', key: 'open' }, async ($, e, next) => {
183 const done = await next(e)
184 if (isOpenFor(e.value, HUB_PANE)) await openPane($)
185 return done
186 }).catch(($, e, next) => next(e))
187 // Another pane took the hub's one panel: this one steps aside rather than wait as a tab.
188 on('state.set', { plugin: 'modz', key: 'stage' }, async ($, e, next) => {
189 const done = await next(e)
190 if (isOffStage(e.value, HUB_PANE)) await $.ui.close({ id: PANE_ID })
191 return done
192 }).catch(($, e, next) => next(e))
193 // Closed by the person: no read marks from here on; toasts and the status line take over.
194 on('ui.close', { id: 'transceiver' }, async ($, e, next) => {
195 setPaneShown(false)
196 void refreshPane()
197 return next(e)
198 }).catch(($, e, next) => next(e))
199}
200
201function onTools(on: On): void {
202 // The agent tools: the model calls them as mcp__transceiver__send, inbox and ask. The ask tool waits inside this
203 // hook's own budget.
204 on('tool.call', { tool: ['mcp__transceiver__send', 'mcp__transceiver__inbox', 'mcp__transceiver__ask'] }, async ($, e, next) => {
205 const name = String(e.tool).slice('mcp__transceiver__'.length)
206 const input = e as unknown as Record<string, unknown>
207 return runTool({ host: agentHostOf($), budgetLeft: () => next.budget.remainingMs }, { name, input })
208 }).catch(() => ({ deny: agentWords().toolFailed }))
209 // Live wake never interrupts a running turn of the main conversation.
210 on('turn.start', async ($, e, next) => { setTurnRunning(true); return next(e) })
211 on('turn.complete', async ($, e, next) => {
212 if (e.agentId === undefined) setTurnRunning(false)
213 return next(e)
214 })
215}
216
217// The command's name is written as a literal (it is COMMAND in limits.ts): the engine reads a literal name to know
218// this hook answers only its own command.
219function onCommand(on: On): void {
220 on('command.run', { command: 'transceiver' }, async ($, e) => {
221 const cmd = parseCommand(e.args)
222 if (cmd.verb === 'ayarlar') patchSession({ view: 'settings' })
223 const text = await runCommand({ host: hostOf($), openPane: () => openPane($), cmd })
224 await refreshPane()
225 return text === null ? {} : { text }
226 }).catch(() => ({ text: agentWords().commandFailed }))
227}
228
229export const register: Register = on => {
230 onSession(on)
231 onPane(on)
232 onTools(on)
233 onCommand(on)
234}
235hooks/actions.ts 191 lines1// What each press and submit in the pane does. Every handler runs against the bound port, catches its own
2// failure (a short line in the interface's language, never tool text or a message body) and redraws.
3import type { RenderSurface } from 'claude-code'
4
5import { isConnected, setConnected } from './connection'
6import { acceptInvite, joinUrlOf, joinRoom } from './join'
7import { askAgent, sendComposed, setTyped, typedOf } from './compose'
8import { isRepo, paths } from './protocol'
9import { pokeRoom, syncOf } from './room-loop'
10import { createRoom, failText, loadRooms, loadSettings, saveRooms, saveSettings, type RoomKind, type RoomRecord } from './rooms'
11import { draftSettlerNow, modelNow, patchSession, portNow, refreshPane, sessionNow, type PanePort } from './state'
12import type { EmptyForm, PaneSettings, PaneView } from './state-types'
13import { readMarkOf } from './view'
14import { stepWords } from './words-steps'
15
16// A consent quotes the label the person pressed, in the language it was shown in.
17const connectConsent = () => `The user pressed "${stepWords().connectLabel}" on the Transceiver pane to send messages from this session.`
18
19type Step = (p: PanePort) => Promise<void>
20
21// Runs one press; a press while a long step is in flight waits its turn by being ignored.
22function run(step: Step): void {
23 const p = portNow()
24 if (p === null) return
25 void step(p).catch(() => patchSession({ error: stepWords().failed, busy: null })).finally(() => void refreshPane())
26}
27
28async function patchRoom(p: PanePort, id: string, patch: Partial<RoomRecord>): Promise<RoomRecord | undefined> {
29 const rooms = (await loadRooms(p.host)).map(r => (r.id === id ? { ...r, ...patch } : r))
30 await saveRooms(p.host, rooms)
31 return rooms.find(r => r.id === id)
32}
33
34async function select(p: PanePort, id: string): Promise<void> {
35 const sync = syncOf(id)
36 const openedMark = sync === undefined ? null : { roomId: id, mark: readMarkOf(sync, sessionNow().localRead[id]) }
37 patchSession({ activeId: id, view: 'chat', error: null, openedMark, composeMode: null, sheetRoom: null, hint: null })
38 const room = (await loadRooms(p.host)).find(r => r.id === id)
39 if (room !== undefined) pokeRoom(p.host, room)
40}
41
42// Accepting publishes this person's own mailbox for the room, so the press is the consent for that write.
43async function accept(p: PanePort, id: string): Promise<void> {
44 const invite = (await loadRooms(p.host)).find(r => r.id === id && r.status === 'invited')
45 if (invite === undefined || sessionNow().busy !== null) return
46 patchSession({ busy: stepWords().busyJoin, error: null })
47 await refreshPane()
48 const consent = `The user pressed "${stepWords().acceptLabel}" on a Transceiver invite`
49 const room = await acceptInvite({ host: p.host, invite, consent }).catch(() => null)
50 if (room === null) return patchSession({ busy: null, error: stepWords().noBox })
51 patchSession({ busy: null, created: { url: room.url, title: room.title } })
52 await select(p, room.id)
53}
54
55async function answerGh(p: PanePort, isYes: boolean): Promise<void> {
56 const ask = modelNow()?.ghAsk
57 const room = ask == null ? undefined : (await loadRooms(p.host)).find(r => r.id === ask.roomId)
58 if (ask == null || room === undefined) return
59 // A "no" is kept too, against this login, so the same question is not asked again.
60 const peer = { uid: ask.uid, gh: ask.gh, isGhConfirmed: isYes }
61 await patchRoom(p, room.id, { peers: [...room.peers.filter(x => x.uid !== ask.uid), peer] })
62}
63
64// The session's first write, raised by the press: the person's own head, written back as it is (spike R1). Any
65// answer but a refusal means the write was allowed, and every later write this session passes without asking.
66async function connect(p: PanePort): Promise<void> {
67 const rooms = (await loadRooms(p.host)).filter(r => r.status === 'active')
68 const room = rooms.find(r => syncOf(r.id) !== undefined)
69 const sync = room === undefined ? undefined : syncOf(room.id)
70 if (room === undefined || sync === undefined) return patchSession({ error: stepWords().notLoaded })
71 patchSession({ busy: stepWords().busyConnect, error: null })
72 await refreshPane()
73 const pin = sync.ownVersion > 0 ? { if_version: sync.ownVersion } : {}
74 const out = await p.host.tool({
75 tool: 'ArtifactData', action: 'set', url: room.url, collection: paths.heads, doc_id: sync.selfUid, data: sync.ownHead,
76 ...pin, consent: connectConsent(),
77 }).catch(() => null)
78 const isOk = out !== null && out.deny === undefined
79 setConnected(isOk)
80 patchSession({ busy: null, error: isOk ? null : connectError(out) })
81 if (isOk) for (const r of rooms) pokeRoom(p.host, r)
82}
83
84// No answer at all is the network; an answer that is not a yes is a refusal.
85const connectError = (out: unknown): string => (out === null ? stepWords().offline : stepWords().declined)
86
87async function addRoom(p: PanePort, opts: { kind: RoomKind; repo?: string; label: string }): Promise<void> {
88 if (sessionNow().busy !== null) return
89 patchSession({ busy: stepWords().busyOpen, error: null, created: null })
90 await refreshPane()
91 const consent = `The user pressed "${opts.label}" on the Transceiver pane`
92 const room = await createRoom({ host: p.host, kind: opts.kind, repo: opts.repo, consent }).catch((err: unknown) => err as Error)
93 if (room instanceof Error) return patchSession({ busy: null, error: failText(room, stepWords().roomFailed) })
94 await saveRooms(p.host, [...(await loadRooms(p.host)), room])
95 pokeRoom(p.host, room)
96 patchSession({ busy: null, view: 'rooms', created: { url: room.url, title: room.title } })
97}
98
99async function join(p: PanePort, link: string): Promise<void> {
100 const url = joinUrlOf(link)
101 if (url === null) return patchSession({ error: stepWords().badLink })
102 patchSession({ busy: stepWords().busyJoin, error: null })
103 await refreshPane()
104 const joined = await joinRoom(p.host, url, `The user pasted a Transceiver link into the pane and pressed "${stepWords().joinLabel}"`)
105 const { room } = joined
106 // Joining opened this person's own mailbox: the card asks them to share it back.
107 const created = joined.isNew && room !== undefined ? { url: room.url, title: room.title } : null
108 patchSession({ busy: null, error: room === undefined ? joined.text : null, created })
109 if (room !== undefined) await select(p, room.id)
110}
111
112async function toggleSetting(p: PanePort, key: keyof PaneSettings): Promise<void> {
113 const settings = await loadSettings(p.host)
114 await saveSettings(p.host, { ...settings, [key]: !settings[key] })
115 if (key === 'isStatusOn' && settings.isStatusOn) p.ui.status(undefined)
116}
117
118async function copy(p: PanePort, opts: { text: string; surface: RenderSurface }): Promise<void> {
119 const isCopied = await p.ui.copy(opts.text, opts.surface)
120 p.ui.toast(isCopied ? stepWords().copied : stepWords().notCopied)
121}
122
123export type PaneHandlers = {
124 select(id: string): void
125 back(): void
126 send(roomId: string, text: string): void
127 accept(id: string): void
128 reject(id: string): void
129 answerGh(isYes: boolean): void
130 connect(): void
131 dismissConnect(): void
132 toggleLive(id: string): void
133 toggleSetting(key: keyof PaneSettings): void
134 openView(view: PaneView): void
135 addDm(): void
136 addProject(repo: string): void
137 join(link: string): void
138 copy(text: string, surface: RenderSurface): void
139 dismissCreated(): void
140 dismissError(): void
141 settleDraft(qid: string, isApproved: boolean): void
142 askAgent(roomId: string, type: string): void
143 type(roomId: string, text: string): void
144 sendTyped(roomId: string): void
145 toggleExpanded(id: string): void
146 toggleSheet(roomId: string): void
147 toggleInfo(roomId: string): void
148 toggleForm(form: EmptyForm): void
149 clearMode(): void
150}
151
152export const paneHandlers: PaneHandlers = {
153 select: id => run(p => select(p, id)),
154 back: () => run(async () => patchSession({ view: 'rooms', error: null })),
155 send: (roomId, text) => run(p => sendComposed(p, { roomId, text })),
156 accept: id => run(p => accept(p, id)),
157 reject: id => run(async p => { await patchRoom(p, id, { status: 'left' }) }),
158 answerGh: isYes => run(p => answerGh(p, isYes)),
159 connect: () => run(p => (isConnected() ? Promise.resolve() : connect(p))),
160 dismissConnect: () => run(async () => patchSession({ isConnectDismissed: true })),
161 toggleLive: id => run(async p => {
162 const room = (await loadRooms(p.host)).find(r => r.id === id)
163 if (room?.kind === 'project') await patchRoom(p, id, { isLive: !room.isLive })
164 }),
165 toggleSetting: key => run(p => toggleSetting(p, key)),
166 openView: view => run(async () => patchSession({ view, error: null })),
167 addDm: () => run(p => addRoom(p, { kind: 'dm', label: stepWords().addDmLabel })),
168 addProject: repo => run(p => (isRepo(repo.trim())
169 ? addRoom(p, { kind: 'project', repo: repo.trim(), label: stepWords().addProjectLabel })
170 : Promise.resolve(patchSession({ error: stepWords().badRepo })))),
171 join: link => run(p => join(p, link)),
172 copy: (text, surface) => run(p => copy(p, { text, surface })),
173 dismissCreated: () => run(async () => patchSession({ created: null })),
174 dismissError: () => run(async () => patchSession({ error: null })),
175 settleDraft: (qid, isApproved) => run(async () => {
176 const isDone = await draftSettlerNow()?.(qid, isApproved)
177 if (isDone === false && !isConnected()) patchSession({ error: stepWords().draftWaits })
178 }),
179 askAgent: (roomId, type) => run(p => askAgent(p, { roomId, type })),
180 type: (roomId, text) => setTyped(roomId, text),
181 sendTyped: roomId => run(p => sendComposed(p, { roomId, text: typedOf(roomId) })),
182 toggleSheet: roomId => run(async () => patchSession({ sheetRoom: sessionNow().sheetRoom === roomId ? null : roomId, hint: null })),
183 toggleInfo: roomId => run(async () => patchSession({ infoRoom: sessionNow().infoRoom === roomId ? null : roomId })),
184 toggleForm: form => run(async () => patchSession({ emptyForm: sessionNow().emptyForm === form ? null : form, error: null })),
185 clearMode: () => run(async () => patchSession({ composeMode: null, hint: null })),
186 toggleExpanded: id => run(async () => {
187 const open = sessionNow().expanded
188 patchSession({ expanded: open.includes(id) ? open.filter(x => x !== id) : [...open, id] })
189 }),
190}
191hooks/agents.ts 23 lines1import { loadDrafts, onDrafts, settleDraft } from './drafts'
2import type { AgentHost } from './host'
3import { wireIdentity } from './identity'
4import { wireLive } from './live'
5import { wireResponder } from './responder-wire'
6import { wireRetention } from './retention'
7import { refreshPane, setDraftSettler, setDraftSource } from './state'
8
9// The agent side on the room loop: the responder, the gh login on the head, live wake, retention (compaction and
10// rollover), and the pane's drafts.
11// A second session start (a reload keeps none of this) unwires the first, so no listener runs twice.
12let unwire: Array<() => void> = []
13
14export function wireAgents(host: AgentHost): void {
15 for (const off of unwire) off()
16 unwire = [
17 wireResponder(host), wireIdentity(host), wireLive(host), wireRetention(host),
18 onDrafts(() => { void refreshPane().catch(() => undefined) }),
19 ]
20 setDraftSource(() => loadDrafts(host))
21 setDraftSettler((qid, isApproved) => settleDraft(host, { qid, isApproved }))
22}
23hooks/commands.ts 119 lines1import type { Host } from './host'
2import { joinRoom } from './join'
3import { pick } from './kit/lang'
4import { COMMAND } from './limits'
5import { isRepo } from './protocol'
6import { forgetRoom, pokeRoom } from './room-loop'
7import { createRoom, failText, loadRooms, saveRooms, type RoomKind } from './rooms'
8
9export type Verb = 'open' | 'ekle' | 'proje' | 'katil' | 'cik' | 'kur' | 'ayarlar' | 'yardim'
10export type Cmd = { verb: Verb; arg: string }
11// What a command needs beyond the host: the pane opener lives in register.tsx, beside `$`.
12export type CommandDeps = { host: Host; openPane: () => Promise<void> }
13
14const FOLD: Record<string, string> = { ç: 'c', ı: 'i', ğ: 'g', ö: 'o', ş: 's', ü: 'u' }
15const fold = (s: string) => s.toLocaleLowerCase('tr').replace(/[çığöşü]/g, c => FOLD[c] ?? c)
16const VERBS = new Set<Verb>(['ekle', 'proje', 'katil', 'cik', 'kur', 'ayarlar'])
17const PANE_VERBS = new Set<Verb>(['open', 'kur', 'ayarlar'])
18// The verbs keep their Turkish names inside; either language's word works when typed.
19const ALIASES: Record<string, Verb> = { add: 'ekle', project: 'proje', join: 'katil', leave: 'cik', connect: 'kur', settings: 'ayarlar' }
20
21const EN = {
22 description: 'Message friends and agents: opens the pane',
23 help: [
24 `/${COMMAND} — open the pane`,
25 `/${COMMAND} add — open a DM room for a friend`,
26 `/${COMMAND} project <owner/repo> — open a shared project room`,
27 `/${COMMAND} join <link> — join with an invite link (invites from another organization do not show in the list)`,
28 `/${COMMAND} leave <room name | link> — leave a room`,
29 `/${COMMAND} connect — connect in this session (one approval for the first message)`,
30 `/${COMMAND} settings — read receipts, notifications, your GitHub name`,
31 ],
32 add: 'add',
33 project: 'project',
34 roomFailed: (why: string) => `The room could not be opened: ${why}`,
35 roomReady: (url: string) => `Room ready, your mailbox: ${url}\nOn claude.ai, add your friend by email from this page's Share menu (Viewer is enough), then send them the link (/${COMMAND} join <link>). When they send you their own mailbox link, type /${COMMAND} join <their link>.`,
36 leaveUsage: `Usage: /${COMMAND} leave <room name | link>`,
37 manyRooms: 'More than one room matches. Try the room\'s link.',
38 noRoom: (name: string) => `No room found: ${name}`,
39 left: (title: string) => `Left ${title}.`,
40 projectUsage: `Usage: /${COMMAND} project <owner/repo>`,
41}
42
43const TR: typeof EN = {
44 description: 'Arkadaşlarınla ve agent\'larla yazış: pencereyi açar',
45 help: [
46 `/${COMMAND} — pencereyi aç`,
47 `/${COMMAND} ekle — arkadaş için DM odası aç`,
48 `/${COMMAND} proje <sahip/repo> — ortak proje odası aç`,
49 `/${COMMAND} katıl <link> — davet linkiyle katıl (başka organizasyondan davetler listede görünmez)`,
50 `/${COMMAND} çık <oda adı | link> — odadan çık`,
51 `/${COMMAND} kur — bu oturumda bağlan (ilk mesaj için tek onay)`,
52 `/${COMMAND} ayarlar — okundu bilgisi, bildirimler, GitHub adın`,
53 ],
54 add: 'ekle',
55 project: 'proje',
56 roomFailed: why => `Oda açılamadı: ${why}`,
57 roomReady: url => `Oda hazır, kendi kutun: ${url}\nclaude.ai'de bu sayfanın Paylaş menüsünden arkadaşını e-postasıyla ekle (Viewer yeter); sonra linki ona gönder (/${COMMAND} katıl <link>). O da kendi kutusunun linkini sana gönderince /${COMMAND} katıl <onun linki> yaz.`,
58 leaveUsage: `Kullanım: /${COMMAND} çık <oda adı | link>`,
59 manyRooms: 'Birden fazla oda eşleşti; odanın linkiyle dene.',
60 noRoom: name => `Oda bulunamadı: ${name}`,
61 left: title => `${title} odasından çıkıldı.`,
62 projectUsage: `Kullanım: /${COMMAND} proje <sahip/repo>`,
63}
64
65const words = () => pick({ en: EN, tr: TR })
66
67// No argument hint: a bare /transceiver opens the pane, where every step is a button. The verbs below still work
68// when typed, and a wrong one answers with the help, which lists the current language's words.
69export const description = (): string => words().description
70export const helpText = (): string => words().help.join('\n')
71
72export function parseCommand(args: string): Cmd {
73 const [head = '', ...rest] = args.trim().split(/\s+/)
74 const arg = rest.join(' ')
75 if (head === '') return { verb: 'open', arg: '' }
76 const word = fold(head)
77 const verb = ALIASES[word] ?? (word as Verb)
78 return VERBS.has(verb) ? { verb, arg } : { verb: 'yardim', arg: head }
79}
80
81// The command the person typed is the consent for the room's first writes (publish and meta).
82async function addRoom(deps: CommandDeps, opts: { kind: RoomKind; repo?: string; typed: string }): Promise<string> {
83 const { host } = deps
84 const consent = `The user ran "/${COMMAND} ${opts.typed}"`
85 const room = await createRoom({ host, kind: opts.kind, repo: opts.repo, consent }).catch((err: unknown) => err as Error)
86 if (room instanceof Error) return failText(room, words().roomFailed(room.message))
87 await saveRooms(host, [...(await loadRooms(host)), room])
88 pokeRoom(host, room)
89 await deps.openPane()
90 return words().roomReady(room.url)
91}
92
93async function leave(host: Host, name: string): Promise<string> {
94 if (name === '') return words().leaveUsage
95 const rooms = await loadRooms(host)
96 const matches = rooms.filter(r => r.status !== 'left' && (r.url === name || r.title.endsWith(name)))
97 if (matches.length > 1) return words().manyRooms
98 const [room] = matches
99 if (room === undefined) return words().noRoom(name)
100 await forgetRoom(host, room.id)
101 await saveRooms(host, rooms.map(r => (r.id === room.id ? { ...r, status: 'left' as const } : r)))
102 return words().left(room.title)
103}
104
105export async function runCommand(opts: CommandDeps & { cmd: Cmd }): Promise<string | null> {
106 const { cmd, host } = opts
107 if (PANE_VERBS.has(cmd.verb)) {
108 await opts.openPane()
109 return null
110 }
111 if (cmd.verb === 'ekle') return addRoom(opts, { kind: 'dm', typed: words().add })
112 if (cmd.verb === 'proje') {
113 return isRepo(cmd.arg) ? addRoom(opts, { kind: 'project', repo: cmd.arg, typed: `${words().project} ${cmd.arg}` }) : words().projectUsage
114 }
115 if (cmd.verb === 'cik') return leave(host, cmd.arg)
116 if (cmd.verb === 'katil') return (await joinRoom(host, cmd.arg)).text
117 return helpText()
118}
119hooks/connection.ts 36 lines1// Whether this session may write to rooms (spike R1). The first artifact write of a session needs the person's
2// approval, so nothing writes until they press "Bağlan" (or create a room, whose publish carries their command
3// as consent). A session in `bypassPermissions` is connected from the start. Until then every write waits and
4// says so with waitingText; reads never wait.
5
6import { pick } from './kit/lang'
7
8export const waitingText = (): string => pick({ en: 'waiting for connection', tr: 'bağlantı bekleniyor' })
9
10let isOn = false
11let save: ((is: boolean) => void) | null = null
12
13export const isConnected = (): boolean => isOn
14
15// The flag is kept in the host's session state too, which outlives a hot reload of this module (a reload starts
16// it at false again): `session.start` reads it back, so a reloaded session stays connected instead of waiting.
17export function persistConnection(fn: (is: boolean) => void): void {
18 save = fn
19}
20
21// A flag that did not change is not written again: every state write redraws the pane.
22export function setConnected(is: boolean): void {
23 if (is === isOn) return
24 isOn = is
25 save?.(is)
26}
27
28// Brings back the flag a reload dropped. A failed read only means asking for "Bağlan" again; it never throws, so it
29// cannot keep the session from starting.
30export async function restoreConnection(read: () => Promise<{ value?: unknown }>): Promise<void> {
31 const saved = await read().catch(() => null)
32 if (saved?.value === true) isOn = true
33}
34
35export const isBypassMode = (mode: unknown): boolean => mode === 'bypassPermissions'
36hooks/here.ts 26 lines1import { sameName } from './gate'
2import { sessionRepo } from './github'
3import type { AgentHost } from './host'
4import { REPO_CACHE_MS } from './limits'
5import type { RoomRecord } from './rooms'
6
7type Memo = { root: string; repo: string | null; at: number }
8let memo: Memo | null = null
9
10// The GitHub repo of the session's project root (its `origin`), read again once a minute or when the root moves.
11export async function repoHere(host: Pick<AgentHost, 'run' | 'root' | 'now'>): Promise<string | null> {
12 const [root, now] = await Promise.all([host.root(), host.now()])
13 if (memo !== null && memo.root === root && now - memo.at < REPO_CACHE_MS) return memo.repo
14 const repo = await sessionRepo(host.run, root).catch(() => null)
15 memo = { root, repo, at: now }
16 return repo
17}
18
19export const forgetRepo = () => { memo = null }
20
21// Agent traffic only ever reaches an active project room of the session's own repo: never a DM, never another repo.
22export function projectRoomFor(rooms: readonly RoomRecord[], repo: string | null): RoomRecord | null {
23 if (repo === null) return null
24 return rooms.find(r => r.status === 'active' && r.kind === 'project' && sameName(r.repo, repo)) ?? null
25}
26hooks/host.ts 47 lines1import type { McpToolResult, ModelCompleteRequest, ModelCompleteResult, Timer } from 'claude-code'
2
3import type { RealPath, Run } from './run'
4
5// What a tool call resolves to, as far as this mod reads it.
6export type CallResult = { text?: string; isError?: boolean; deny?: string; result?: unknown }
7
8// The engine's reach, bound in register.tsx: the engine follows `$` only into functions of the hooks module's own
9// file, so every other module gets this port instead (the reflect-mod `io($)` pattern).
10export type Host = {
11 tool(input: Record<string, unknown>): Promise<CallResult>
12 storeGet(key: string): Promise<unknown>
13 storeSet(key: string, value: unknown): Promise<void>
14 now(): Promise<number>
15 after(ms: number, fn: () => void): Timer
16 every(ms: number, fn: () => void): Timer
17 home(): Promise<string | undefined>
18 writeFile(path: string, text: string): Promise<void>
19 log(text: string): void
20 // The session's working directory: the Artifact tool publishes only files under it (or Claude's scratchpad).
21 cwd(): Promise<string>
22 isDir(path: string): Promise<boolean>
23 // A file, or an empty directory; anything else is left alone.
24 remove(path: string): Promise<void>
25 // Whether this session may write to rooms yet (connection.ts; spike R1). The poll loop only reads until then.
26 isConnected(): boolean
27}
28
29export type ToolHost = Pick<Host, 'tool'>
30
31// The agent side's reach (T13–T14), bound beside Host in register.tsx: commands, files, the model, the session's
32// own conversation. `isConnected` says whether this session may write yet (the pane's "Bağlan" press, spike R1).
33export type AgentHost = Host & {
34 run: Run
35 realPath: RealPath
36 readFile(path: string): Promise<string>
37 root(): Promise<string>
38 readState(plugin: string, key: string): Promise<unknown>
39 mcpCall(server: string, tool: string, args?: Record<string, unknown>): Promise<McpToolResult>
40 complete(request: ModelCompleteRequest): Promise<ModelCompleteResult>
41 sleep(ms: number): Promise<void>
42 sendSelf(text: string): Promise<boolean>
43 note(text: string): Promise<void>
44 toast(text: string): void
45 isConnected(): boolean
46}
47hooks/hub.ts 32 lines1// The MODZ hub's side of Transceiver (MODZ DESIGN.md §9): the one line the hub shows for it, and the hub's open
2// requests, which name a pane by id. Pure: register.tsx writes the line, and only when it changed.
3import type { HubCard, HubTone } from '../types'
4import { isSame } from './kit/same'
5import type { RoomView } from './state-types'
6import { agentWords } from './words-agent'
7
8export const HUB_PANE = 'transceiver'
9
10export type HubShown = { detail: string; tone: HubTone }
11
12// "2 unread · 3 rooms" while something waits to be read (live, so the hub may lead with it), "3 oda" otherwise;
13// nothing to say before the first room.
14export function hubOf(rooms: readonly RoomView[]): HubShown {
15 if (rooms.length === 0) return { detail: '', tone: 'idle' }
16 const unread = rooms.reduce((sum, room) => sum + room.unread, 0)
17 const w = agentWords()
18 const count = w.rooms(rooms.length)
19 if (unread === 0) return { detail: count, tone: 'idle' }
20
21 return { detail: `${w.unreadHub(unread)} · ${count}`, tone: 'live' }
22}
23
24// The card already written says the same: writing it again would only redraw the hub.
25export function isSameLine(card: HubCard | undefined, shown: HubShown): boolean {
26 return card !== undefined && isSame({ detail: card.detail, tone: card.tone }, shown)
27}
28
29export function isOpenFor(value: unknown, pane: string): boolean {
30 return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
31}
32hooks/kit/lang.ts 24 lines1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12 current = langOf(value)
13
14 return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24hooks/kit/pin.ts 48 lines1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18 let handle = pins.get(id)
19 if (handle === undefined) {
20 handle = FIRST_PINNED + pins.size
21 pins.set(id, handle)
22 }
23
24 return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28 if (typeof node !== 'object' || node === null) return node
29 const el = node as Node
30 const children = el.children?.map(child => pinNode(child, ctx))
31 const withKids = children === undefined ? el : { ...el, children }
32 // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33 if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34 const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35 const nth = ctx.seen.get(base) ?? 0
36 ctx.seen.set(base, nth + 1)
37
38 return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43 if (tree === null || tree === undefined) return tree
44 const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46 return pinNode(tree, ctx) as T
47}
48hooks/kit/stage.ts 30 lines1// MODZ kit — one panel (DESIGN.md §9). The hub and every mod's pane take turns in one place instead of
2// piling up as tabs: a mod that opens its pane writes its own `{ plugin: '<mod>', key: 'shown' }` (`{ pane, at }`);
3// the hub then steps aside and writes `{ plugin: 'modz', key: 'stage' }` naming that pane, and every other mod
4// whose pane is open closes it. The hub coming back (its row, "← MODZ", /modz) writes the stage as `modz`.
5
6export const HUB_PANE = 'modz'
7
8export type Stage = { pane: string; at: number }
9
10// The pane a `modz.open`, `modz.stage` or `shown` value names; null for anything malformed.
11export const paneOf = (value: unknown): string | null => {
12 if (typeof value !== 'object' || value === null) return null
13 const pane = (value as { pane?: unknown }).pane
14
15 return typeof pane === 'string' && pane !== '' ? pane : null
16}
17
18// Whether a `modz.open` write asks for this pane.
19export const isAskedFor = (value: unknown, pane: string): boolean => paneOf(value) === pane
20
21// Whether a `modz.stage` write puts another pane in the panel, so this one closes.
22export function isOffStage(value: unknown, pane: string): boolean {
23 const staged = paneOf(value)
24
25 return staged !== null && staged !== pane
26}
27
28// What a mod writes under its own `shown` key once its pane is open.
29export const shownOf = (pane: string, at: number): Stage => ({ pane, at })
30hooks/kit/tokens.ts 263 lines1// MODZ kit (Tidemark) — source of truth is kit/ in the MODZ repo; bin/kit-sync copies it
2// into each mod's hooks/kit/. Edit there, never in a copy. Pure: no `$`, no state.
3
4// ---------------------------------------------------------------------------
5// Palettes: DESIGN.md §2, both themes, exactly.
6
7export const ROLES = ['read', 'edit', 'run', 'ok', 'warn', 'danger', 'plan'] as const
8export type Role = (typeof ROLES)[number]
9export type RoleColors = { fg: string; tint: string; line: string }
10
11export type Neutrals = {
12 sheet: string
13 card: string
14 inset: string
15 line: string
16 lineStrong: string
17 track: string
18 hi: string
19 mid: string
20 lo: string
21}
22
23export type Palette = Neutrals & { roles: Record<Role, RoleColors> }
24
25const role = (fg: string, tint: string, line: string): RoleColors => ({ fg, tint, line })
26
27export const DARK: Palette = {
28 sheet: '#191917',
29 card: '#1F1F1C',
30 inset: '#171715',
31 line: '#2E2E2A',
32 lineStrong: '#45443F',
33 track: '#34332F',
34 hi: '#F0EEE8',
35 mid: '#C2BEB4',
36 lo: '#8E8A80',
37 roles: {
38 read: role('#7FB6E8', '#1E2B37', '#2F4A63'),
39 edit: role('#E58A5F', '#38251D', '#6A3E2D'),
40 run: role('#B19CF3', '#29243D', '#4A3F75'),
41 ok: role('#6FC593', '#1B2F25', '#2F5A42'),
42 warn: role('#E9B949', '#332D19', '#66552A'),
43 danger: role('#F2727F', '#3B2023', '#74343C'),
44 plan: role('#E07BC0', '#351F2E', '#6B3A5C'),
45 },
46}
47
48export const LIGHT: Palette = {
49 sheet: '#F4F2EC',
50 card: '#FFFFFF',
51 inset: '#F6F4EF',
52 line: '#E4E0D6',
53 lineStrong: '#CFC9BC',
54 track: '#ECE8DF',
55 hi: '#1C1B19',
56 mid: '#4F4B44',
57 lo: '#77726A',
58 roles: {
59 read: role('#2464A0', '#E7F0F9', '#B9D3EC'),
60 edit: role('#A94A26', '#FBEDE6', '#EFC6B4'),
61 run: role('#6243C4', '#F0ECFC', '#CFC3F3'),
62 ok: role('#1F6E43', '#E4F4EA', '#B5DEC4'),
63 warn: role('#8A6000', '#FBF2D9', '#EBD48F'),
64 danger: role('#C0263A', '#FCE8EA', '#F1B9C0'),
65 plan: role('#A8337F', '#FBE9F4', '#EDBFDC'),
66 },
67}
68
69export const palette = (isLight: boolean): Palette => (isLight ? LIGHT : DARK)
70
71// ---------------------------------------------------------------------------
72// File-type badges: soft, one colour per kind; the activity colour (read/edit) wins on a row.
73
74export type FileKind = 'code' | 'py' | 'doc' | 'data' | 'pdf' | 'img' | 'misc'
75export type BadgeColors = { fg: string; tint: string }
76
77const KIND_ROLE: Record<FileKind, Role | null> = {
78 code: 'warn',
79 py: 'read',
80 doc: null,
81 data: 'ok',
82 pdf: 'danger',
83 img: 'plan',
84 misc: null,
85}
86
87// Kinds with no role of their own: md/txt are a pale Iris, sh/git and the unknown are text.lo.
88// Light misc uses text.mid on track: text.lo misses 4.5:1 on every light tint.
89const OWN_BADGES: Record<'doc' | 'misc', { dark: BadgeColors; light: BadgeColors }> = {
90 doc: { dark: { fg: '#B8AADA', tint: '#252230' }, light: { fg: '#5A4691', tint: '#F6F4FD' } },
91 misc: { dark: { fg: DARK.lo, tint: DARK.inset }, light: { fg: LIGHT.mid, tint: LIGHT.track } },
92}
93
94const EXT_KIND: Record<string, FileKind> = {
95 ts: 'code', tsx: 'code', js: 'code', jsx: 'code', mjs: 'code', cjs: 'code', mts: 'code', cts: 'code',
96 py: 'py', pyi: 'py',
97 md: 'doc', mdx: 'doc', txt: 'doc', rst: 'doc',
98 json: 'data', csv: 'data', yaml: 'data', yml: 'data', toml: 'data', xml: 'data',
99 pdf: 'pdf',
100 png: 'img', jpg: 'img', jpeg: 'img', gif: 'img', svg: 'img', webp: 'img', ico: 'img',
101}
102
103const EXT_LABEL: Record<string, string> = { json: '{}', yaml: 'YML', jpeg: 'JPG' }
104const BADGE_CHARS = 3
105
106export type FileType = { kind: FileKind; label: string }
107
108// `src/api/client.ts` → code "TS"; `.gitignore` → misc "GIT"; `Makefile` → misc "MAK".
109export function fileType(name: string): FileType {
110 const base = name.split('/').pop() ?? name
111 const dot = base.lastIndexOf('.')
112 const ext = (dot >= 0 ? base.slice(dot + 1) : base).toLowerCase()
113 const isGit = base.toLowerCase().startsWith('.git')
114 const kind = isGit ? 'misc' : EXT_KIND[ext] ?? 'misc'
115 const label = isGit ? 'GIT' : EXT_LABEL[ext] ?? ext.slice(0, BADGE_CHARS).toUpperCase()
116
117 return { kind, label: label || '·' }
118}
119
120export function badgeColors(kind: FileKind, isLight: boolean): BadgeColors {
121 const owner = KIND_ROLE[kind]
122
123 if (owner !== null) {
124 const { fg, tint } = palette(isLight).roles[owner]
125 return { fg, tint }
126 }
127
128 return OWN_BADGES[kind === 'doc' ? 'doc' : 'misc'][isLight ? 'light' : 'dark']
129}
130
131// The CSS variable names a badge paints with inside an Svg (see svgStyle).
132export function badgePaints(kind: FileKind): { fg: string; tint: string } {
133 const owner = KIND_ROLE[kind]
134 const name = owner ?? `file-${kind === 'doc' ? 'doc' : 'misc'}`
135
136 return { fg: name, tint: `${name}-tint` }
137}
138
139// ---------------------------------------------------------------------------
140// Terminal: theme keys, never hex (DESIGN.md §2). Meta text is `dimColor`, not a key.
141
142export type ThemeKey = 'suggestion' | 'claude' | 'permission' | 'success' | 'warning' | 'error' | 'merged' | 'subtle'
143
144export const ROLE_KEY: Record<Role, ThemeKey> = {
145 read: 'suggestion',
146 edit: 'claude',
147 run: 'permission',
148 ok: 'success',
149 warn: 'warning',
150 danger: 'error',
151 plan: 'merged',
152}
153
154export const LINE_KEY: ThemeKey = 'subtle'
155
156// ---------------------------------------------------------------------------
157// Motion (DESIGN.md §5).
158
159export const MOTION = {
160 quickMs: 120,
161 stateMs: 200,
162 travelMs: 320,
163 ease: 'cubic-bezier(.2,.8,.2,1)',
164 breatheS: 1.8,
165 shimmerS: 1.4,
166 breatheLow: 0.45,
167} as const
168
169// ---------------------------------------------------------------------------
170// Svg style: every colour as a CSS variable for both schemes, a fill (`f-*`) and stroke
171// (`s-*`) class per variable, the two loops, and the reduced-motion rule last.
172
173const kebab = (name: string): string => name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)
174
175export function paintVars(isLight: boolean): Record<string, string> {
176 const p = palette(isLight)
177 const vars: Record<string, string> = {}
178 const { roles, ...neutrals } = p
179
180 for (const [name, hex] of Object.entries(neutrals)) vars[kebab(name)] = hex
181 for (const name of ROLES) {
182 vars[name] = roles[name].fg
183 vars[`${name}-tint`] = roles[name].tint
184 vars[`${name}-line`] = roles[name].line
185 }
186 for (const kind of ['doc', 'misc'] as const) {
187 const own = OWN_BADGES[kind][isLight ? 'light' : 'dark']
188 vars[`file-${kind}`] = own.fg
189 vars[`file-${kind}-tint`] = own.tint
190 }
191
192 return vars
193}
194
195export const PAINTS = Object.keys(paintVars(false))
196
197const LOOPS: Record<string, string> = {
198 breathe: `@keyframes breathe{50%{opacity:${MOTION.breatheLow}}}.breathe{animation:breathe ${MOTION.breatheS}s ease-in-out infinite}`,
199 shimmer: `@keyframes shimmer{0%,100%{opacity:1}50%{opacity:.4}}.shimmer{animation:shimmer ${MOTION.shimmerS}s linear infinite}`,
200}
201
202export const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', system-ui, sans-serif`
203export const MONO = `ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace`
204
205const REDUCED = '@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}'
206
207const varBlock = (vars: Record<string, string>, names: readonly string[]): string =>
208 `svg{${names.map(name => `--${name}:${vars[name]}`).join(';')}}`
209
210// The <style> body for an Svg. Given the markup it styles, only the paints it names are
211// written, so a small pill does not carry the whole palette.
212export function svgStyle(body?: string): string {
213 const used = body === undefined ? PAINTS : PAINTS.filter(name => new RegExp(`[fs]-${name}\\b(?!-)`).test(body))
214 const rules = used.map(name => `.f-${name}{fill:var(--${name})}.s-${name}{stroke:var(--${name})}`).join('')
215 const loops = Object.keys(LOOPS)
216 .filter(name => body === undefined || body.includes(name))
217 .map(name => LOOPS[name])
218 .join('')
219
220 return [
221 varBlock(paintVars(false), used),
222 `@media (prefers-color-scheme: light){${varBlock(paintVars(true), used)}}`,
223 `text{font-family:${FONT}}.mono{font-family:${MONO};font-variant-numeric:tabular-nums}`,
224 rules,
225 loops,
226 REDUCED,
227 ].join('')
228}
229
230// ---------------------------------------------------------------------------
231// Light or dark, for Box/Text hex. Adapted from skins' light.ts (mods/skins/hooks/light.ts,
232// MODZ): an override (`MODZ_THEME`) wins, then a theme name that says light or dark, then
233// for `auto` the terminal's COLORFGBG, then the system's appearance. Dark when nothing says.
234
235export type ThemeHints = {
236 override?: string
237 theme?: unknown
238 colorfgbg?: string
239 systemDark?: boolean
240}
241
242const LIGHT_ANSI_BACKGROUNDS = [7, 15]
243
244const named = (value: unknown): boolean | undefined => {
245 const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
246
247 if (name.includes('light')) return true
248
249 return name.includes('dark') ? false : undefined
250}
251
252const terminalLight = (colorfgbg: string | undefined): boolean | undefined => {
253 const background = Number(colorfgbg?.split(';').at(-1))
254
255 return colorfgbg === undefined || Number.isNaN(background) ? undefined : LIGHT_ANSI_BACKGROUNDS.includes(background)
256}
257
258export const resolveLight = (hints: ThemeHints): boolean =>
259 named(hints.override) ??
260 named(hints.theme) ??
261 terminalLight(hints.colorfgbg) ??
262 (hints.systemDark === undefined ? false : !hints.systemDark)
263