SLOPSHOPPER

transceiver

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…

newpaneguardcommandtooltimer
v?MITupdated 2026-10-16partalemre/transceiver
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · transceiver
│ ┃ Transceiver ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Message your friends from here Beta ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Add a friend. Once they accept, you talk wit ⏺ Update(src/auth.ts) │ ┃ Claude. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Add a friend ] I have a link ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Settings │ ✻ Worked for 42s · done 4:20 PM │ │ › /transceiver │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Transceiver
Message your friends from here Beta Add a friend. Once they accept, you talk without leaving Claude. [ Add a friend ] I have a link Settings
README

<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.

Install

/plugin marketplace add partalemre/MODZ
/plugin install transceiver@modz

Then type /transceiver. The pane opens, and every step is a button there.

  • Requirements. Claude Code 2.1.295 or later, signed in with a claude.ai account.
  • SDK hosts. If Claude Code runs inside another app (an IDE, T3 Code, 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.
  • Language. The interface is English. Set MODZ_LANG=tr for Turkish.

How it works

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.

  1. Open a room. In the pane, Add a friend opens a DM; Project room opens a room for a repo you share, prefilled with the session's repo. The pane shows your mailbox link.
  2. Share your mailbox. Open the link on claude.ai, add your friend by email from the page's Share menu, and send them the link. This step is manual because claude.ai has no sharing API.
  3. They join. In the same organization, an invite card appears in their pane within a minute. From another organization, invites are not listed, so they paste your link into I have a link (or type /transceiver join <link>). Joining opens their own mailbox.
  4. They share back. They share their mailbox with you the same way and send you its link; until you have read one of their lines, their chat keeps that link on screen with Copy link. Paste it into I have a link on your side.
  5. Connect. The first message of a session asks Claude Code for one approval. A session started with permissions bypassed is connected from the start.
  6. Match GitHub names (project rooms). Each side writes its GitHub login into its own mailbox, and the chat asks once: "Is Leo @leo on GitHub?". Say yes, and that person's agent may ask about the repo.

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.

Commands

Every command is also a button in the pane. The Turkish words (ekle, proje, katıl, çık, kur, ayarlar) work too.

CommandWhat it does
/transceiverOpens the pane
/transceiver addOpens 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 connectConnects this session (one approval for the first message)
/transceiver settingsRead receipts, notifications, your GitHub name

Agent tools

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 typeAnswer
statusBranch and number of changed files
branchBranches
logThe last 20 commits
diffUncommitted changes, up to 64 KB
testsThe last test result (from test-badge)
planThe plan's steps (from plan-steps)
fileOne file, up to 256 KB
grepLines that contain a text, optionally in one folder
filesFile names, optionally in one folder
todoTODO and FIXME lines
depsDependencies
commitOne commit
askA 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.

Privacy and safety

  • Where things are. What you write is only in your own mailbox, which only the people you shared it with can read. Only its owner can write to a mailbox, and the server enforces that. The mod ties each mailbox to one author, so lines written there under someone else's id are ignored. Your settings, room list and daily counts stay on your machine.
  • What is never written. No session name, folder path, model name or repo list reaches any room. The only identity written is your GitHub login, and only into project rooms you accepted. A repo name appears only in that repo's room.
  • The gate. An agent question is answered only when all four hold. You confirmed the person on the GitHub card. They are a collaborator on the repo, which is checked with GitHub and cached for 10 minutes. The session is open in that repo. The room is that repo's project room. If a check cannot be made, the gate counts as closed. Every refusal reads the same: "no access".
  • Files. Paths must stay inside the repo root, with links resolved. .env*, *.pem, *.key, id_*, .git/, .ssh/ and everything .gitignore covers are refused.
  • Secret scan. Every outgoing answer and agent message is scanned with secret-guard's patterns. On a hit nothing is sent, and the other side sees "no access".
  • One answer. A claim written once to the room keeps two of your sessions from answering the same question, so each question is answered exactly once.

Token cost

Messaging, polling and model-free questions use no model tokens: the mod calls Claude Code's tools directly, outside any model turn.

  • Free-form 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.
  • Live mode. It is off by default and set per project room. When on, a teammate's message wakes your idle session in that repo, and each wake is a model turn. It wakes at most every 30 seconds and never interrupts a running turn.

Limits

  • Format. No group chat, end-to-end encryption or attachments. Chat happens only in Claude Code; the mailbox page in a browser shows no chat.
  • Size and rate. Up to 16 KB per message, and up to 60 questions per minute per room.
  • Polling. Every second with the pane open, every 5 seconds with it closed, doubling on errors up to 30 seconds. Invites are checked once a minute.
  • Compaction. Messages older than 7 days are compacted once a day into one bundle per person per day.
  • Mailbox size. An artifact holds 25,000 documents. At 20,000, the mod opens a new mailbox and writes its link into the old one, and the other members follow it by themselves. Share the new mailbox again from the Share menu.

Troubleshooting

You seeDo this
No invite cardInvites 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" noticeYour new mailbox is open. Share its link again from the Share menu.

Develop

  • Tests. 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.
  • End to end. 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.
  • Two accounts. For a check across two real accounts, follow docs/e2e-checklist.md.

License

MIT.

Source 91 files
hooks/register.tsx 235 lines
1import 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}
235
hooks/actions.ts 191 lines
1// 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}
191
hooks/agents.ts 23 lines
1import { 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}
23
hooks/commands.ts 119 lines
1import 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}
119
hooks/connection.ts 36 lines
1// 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'
36
hooks/here.ts 26 lines
1import { 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}
26
hooks/host.ts 47 lines
1import 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}
47
hooks/hub.ts 32 lines
1// 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}
32
hooks/kit/lang.ts 24 lines
1// 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)
24
hooks/kit/pin.ts 48 lines
1// 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}
48
hooks/kit/stage.ts 30 lines
1// 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 })
30
hooks/kit/tokens.ts 263 lines
1// 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