SLOPSHOPPER

rp

/rp attaches a ReadyRig project to the session: its AGENTS.md, CLAUDE.md and skills, until /rp detach; /rp link attaches it whenever Claude Code starts in this…

newpanecommandtoaststatusprompt
★ 1v0.4.0no licenseupdated 2026-10-14jo32/readyrig/integrations/claude-code/rp
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rp
│ ┃ ReadyRig projects ✕ › fix the failing auth test and add an audit log call │ ┃ Search: : project, path or machine ⏎ attach │ ┃ ⏺ Read(src/auth.ts) │ ┃ Could not list machines: ok ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /rp │ ⎿ rp: Pick a ReadyRig project to attach in the pane (Esc to cancel │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ReadyRig projects
Search: : project, path or machine ⏎ attach first Could not list machines: ok
README

rp: attach a ReadyRig project to Claude Code

rp is a Claude Code mod (a plugin of function hooks). It adds a /rp command that lists the approved projects on every ReadyRig computer, grouped by machine. The project you pick is attached to the session: Claude gets its AGENTS.md, CLAUDE.md and skills, and works in it until you detach it. Link a folder to a project with /rp link, and every Claude Code session you start in that folder attaches the project by itself.

jiangdailins-MacBook-Pro.local (darwin)
    agent_workspace      /Users/jo32/agent_workspace
  ● computer-use-server  /Users/jo32/Projects/computer-use-server
    tvbox                /Users/jo32/Projects/tvbox

jo32-machine-linux (linux)
    agent_workspace      /root/agent_workspace

Requirements

  • Claude Code with function-hook plugins (2.1.291 or newer).
  • The ReadyRig MCP server connected to Claude Code, so the tools mcp__readyrig__list_computers and mcp__readyrig__call_computer_tool are available. /mcp shows whether it is connected.
  • At least one computer that is online and reachable (a ready tunnel, or relay connected). Offline machines are listed as offline, without projects.

Install

From GitHub, at the Claude Code prompt in a terminal:

/plugin install rp --marketplace jo32/readyrig

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). This repository's .claude-plugin/marketplace.json lists the mod.

From a local clone:

claude plugin marketplace add /path/to/readyrig
claude plugin install rp@readyrig

To try it without installing, or while changing it, load the folder for one session. Saving a file reloads the mod:

claude --plugin-dir /path/to/readyrig/integrations/claude-code/rp

Use

CommandWhat it does
/rpOpens a pane with every machine and its projects, and a search box at the top. Type to narrow the list, then press Enter to attach the first match. Or move with Tab or the arrow keys, press Enter on a project to attach it, or Esc to close the pane.
/rp detachDetaches the project (/rp clear does the same). The pane also has a detach button while a project is attached.
/rp linkLinks the folder Claude Code runs in to the attached project. With nothing attached, it opens the pane: the project you pick is attached and linked. The pane also has a link to this folder button.
/rp unlinkRemoves the folder's link. The project stays attached to the session until you detach it.

When you pick a project, the pane shows each step as it runs:

Attaching OpenWorkBuddy2 @ jiangdailins-MacBook-Pro.local

  ✓ AGENTS.md                43 lines
  – CLAUDE.md                none
  … .agents/skills           reading 3 skills
  – .claude/skills           none
  · Add to the conversation

The status line counts the steps too, so you can close the pane with Esc and keep working while it loads. When it is done, the pane closes, a toast says what was found, and the status line shows rp: <project> @ <machine> · AGENTS.md · 3 skills for as long as the project stays attached (· linked at the end when the folder is linked to it).

What Claude gets:

  • Where to work: the machine's computer_id and the project id to pass to the ReadyRig tools. The folder's path is left out, since it can include your user name.
  • Instructions: AGENTS.md (the agents.md standard) and CLAUDE.md from the project's root, each in full up to 40,000 characters. A CLAUDE.md that only says @AGENTS.md is skipped. Claude is also told to look for an AGENTS.md or CLAUDE.md in subfolders, where the nearest one wins.
  • Skills: each SKILL.md in .agents/skills/ (the universal location) and .claude/skills/, including linked folders. Only the name and description from each skill's front matter go in. Claude reads the whole SKILL.md when a task matches. If both folders have a skill with the same name, the one in .agents/skills/ wins.

All of this goes in once, as a hidden note at the end of the conversation. It doesn't go into the system prompt, so attaching or detaching doesn't invalidate the prompt cache for the conversation so far. Your prompts are sent as you typed them. If /compact or /clear drops the note, it is added again before your next prompt. Attaching another project replaces the first one. Detaching adds a short note that tells Claude the project no longer applies.

Linking a folder

A link ties a folder on the computer that runs Claude Code (the session's project root, such as ~/tvbox) to one ReadyRig project. When Claude Code starts in that folder, or in a folder below it, the mod attaches the linked project while Claude Code starts. The nearest linked folder wins. Your first prompt waits up to 8 seconds for the attach to finish, so Claude has the project's note before it answers. If the ReadyRig MCP server isn't connected yet, the mod tries again every 2 seconds for up to 30 seconds. If the machine is offline, the project is gone, or you deny the ReadyRig call, a toast says why and nothing is attached. Starting Claude Code may ask you to allow mcp__readyrig__list_computers and mcp__readyrig__call_computer_tool.

Links are kept in the mod's own store, so they last across sessions. Each folder has at most one link; linking it again replaces the link. Detaching doesn't remove the link, so the next session attaches the project again. Use /rp unlink to stop that.

The search matches project names, paths and machine names, ignoring case. With several words, a project must match all of them (mac tvbox). Machines with no match are hidden while you search.

The pane lines up project names in one column. When the pane is too narrow for the name and path side by side, each path goes on its own indented line under the name.

How it works

/rp calls the ReadyRig MCP tools through Claude Code, the same way Claude would: list_computers, then list_projects on each online computer through call_computer_tool. Attaching uses read_file and list_directory on the project. These calls go through Claude Code's normal permission checks, so you may be asked to allow them the first time.

FilePurpose
.claude-plugin/plugin.jsonManifest
hooks/hooks.jsonPoints Claude Code at the hooks module
hooks/register.tsxThe /rp command, the pane, attaching and detaching, linking folders, and the prompt hook that restores the note
types/index.d.tsTypes for the values the mod keeps in session state
tests/rp.test.tsAttaches projects using fake ReadyRig answers, then checks the note, the progress steps, search, re-adding the note after compaction, detach, and linking folders

Develop

claude plugin validate integrations/claude-code/rp
claude plugin test integrations/claude-code/rp

After Claude Code has loaded the mod once, .claude-plugin/types/ holds the API types (it is git-ignored), and tsc -p integrations/claude-code/rp type-checks it.

Source 2 files
hooks/register.tsx 762 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  RpAttached,
6  RpLink,
7  RpMachine,
8  RpPick,
9  RpProgress,
10  RpProject,
11  RpSkill,
12  RpStep,
13} from '../types'
14
15const PANE = 'rp'
16const machines = atom({ plugin: 'rp', key: 'machines' } as const, [])
17const isLoading = atom({ plugin: 'rp', key: 'isLoading' } as const, false)
18const error = atom({ plugin: 'rp', key: 'error' } as const, null)
19const attached = atom({ plugin: 'rp', key: 'attached' } as const, null)
20const progress = atom({ plugin: 'rp', key: 'progress' } as const, null)
21const needsNote = atom({ plugin: 'rp', key: 'needsNote' } as const, false)
22const query = atom({ plugin: 'rp', key: 'query' } as const, '')
23const link = atom({ plugin: 'rp', key: 'link' } as const, null)
24const isLinking = atom({ plugin: 'rp', key: 'isLinking' } as const, false)
25
26// $.store key of the links, folder → project, kept across sessions.
27const LINKS = 'links'
28// While Claude Code starts, the ReadyRig MCP server may not be connected yet.
29const CONNECT_TRIES = 15
30const CONNECT_WAIT_MS = 2_000
31// How long the first prompt waits for that attach.
32const LINK_WAIT_MS = 8_000
33
34// The AGENTS.md standard (agents.md) and the universal skills folder first,
35// then Claude Code's own. A skill name found in both folders is listed once.
36const INSTRUCTION_FILES = ['AGENTS.md', 'CLAUDE.md']
37const SKILL_DIRS = ['.agents/skills', '.claude/skills']
38// Past this the note says where to read the rest, so one huge file can't fill the context.
39const INSTRUCTIONS_MAX = 40_000
40const NOTE_STEP = 'note'
41
42type ProjectFile = { path: string; text: string; isTruncated: boolean }
43
44// The ReadyRig MCP tools answer JSON text; pull the object out of it.
45function parseJson(text: string | undefined): any {
46  if (!text) throw new Error('empty answer')
47  const start = text.indexOf('{')
48  const end = text.lastIndexOf('}')
49  if (start < 0 || end < start) throw new Error(text.slice(0, 200))
50  return JSON.parse(text.slice(start, end + 1))
51}
52
53async function callTool($: EngineInterface, args: Record<string, unknown>) {
54  return answerOf(await $.tool.call(args as any))
55}
56
57function answerOf(ran: any) {
58  if (ran.deny) throw new Error(ran.deny)
59  if (ran.isError) throw new Error(ran.text ?? 'tool failed')
60  return parseJson(ran.text)
61}
62
63// One tool of a ReadyRig computer; a missing file and the like reject.
64async function callComputer(
65  $: EngineInterface,
66  machineId: string,
67  toolName: string,
68  args: Record<string, unknown>,
69) {
70  const out = await callTool($, {
71    tool: 'mcp__readyrig__call_computer_tool',
72    computer_id: machineId,
73    tool_name: toolName,
74    arguments: args,
75  })
76  if (out.status === 'error' || out.error) throw new Error(String(out.error || 'tool failed'))
77  return out.result ?? {}
78}
79
80async function loadMachine($: EngineInterface, c: any): Promise<RpMachine> {
81  const machine: RpMachine = {
82    id: c.id,
83    name: c.name,
84    platform: c.platform,
85    online: Boolean(c.online),
86    projects: [],
87  }
88  if (!machine.online) return { ...machine, error: 'offline' }
89  try {
90    const out = await callComputer($, c.id, 'list_projects', {})
91    const projects: RpProject[] = (out.projects ?? []).map((p: any) => ({
92      id: String(p.id),
93      name: String(p.name),
94      path: String(p.path),
95    }))
96    return { ...machine, projects }
97  } catch (err) {
98    return { ...machine, error: String((err as Error).message ?? err) }
99  }
100}
101
102async function load($: EngineInterface) {
103  await update($, isLoading, () => true)
104  await update($, error, () => null)
105  try {
106    const out = await callTool($, { tool: 'mcp__readyrig__list_computers' })
107    const list = await Promise.all(
108      (out.computers ?? []).map((c: any) => loadMachine($, c)),
109    )
110    await update($, machines, () => list)
111  } catch (err) {
112    await update($, error, () => String((err as Error).message ?? err))
113  }
114  await update($, isLoading, () => false)
115}
116
117// Every word of the search must appear in the project's name or path, or in
118// its machine's name. With a search, machines left without a match are hidden.
119function filterMachines(list: RpMachine[], text: string): RpMachine[] {
120  const words = text.toLowerCase().split(/\s+/).filter(Boolean)
121  if (words.length === 0) return list
122  return list
123    .map(m => ({
124      ...m,
125      projects: m.projects.filter(p => {
126        const hay = `${p.name} ${p.path} ${m.name}`.toLowerCase()
127        return words.every(w => hay.includes(w))
128      }),
129    }))
130    .filter(m => m.projects.length > 0)
131}
132
133// A project file's text, or null when it is missing or unreadable.
134async function readText(
135  $: EngineInterface,
136  p: RpPick,
137  path: string,
138  limit?: number,
139): Promise<ProjectFile | null> {
140  try {
141    const out = await callComputer($, p.machineId, 'read_file', {
142      project: p.project.id,
143      path,
144      ...(limit ? { limit } : {}),
145    })
146    if (typeof out.content !== 'string') return null
147    return { path, text: out.content, isTruncated: Boolean(out.truncated) }
148  } catch {
149    return null
150  }
151}
152
153// The name and description lines of a SKILL.md's YAML front matter. Enough
154// YAML for skills: plain or quoted values, and folded or indented ones.
155export function frontMatter(text: string): Record<string, string> {
156  const lines = text.split(/\r?\n/)
157  if (lines[0]?.trim() !== '---') return {}
158  const out: Record<string, string> = {}
159  let key: string | null = null
160  for (const line of lines.slice(1)) {
161    if (line.trim() === '---') break
162    const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
163    if (m) {
164      const [, name = '', raw = ''] = m
165      const value = raw.trim()
166      key = name
167      out[key] = /^[>|][+-]?$/.test(value) ? '' : value.replace(/^(["'])(.*)\1$/, '$2')
168    } else if (key && /^\s/.test(line)) {
169      out[key] = `${out[key]} ${line.trim()}`.trim()
170    }
171  }
172  return out
173}
174
175function plural(n: number, word: string): string {
176  return `${n} ${word}${n === 1 ? '' : 's'}`
177}
178
179// The pane's list of steps, and the count on the status line, as they run.
180async function setStep($: EngineInterface, id: string, patch: Partial<RpStep>) {
181  const next = await update($, progress, cur =>
182    cur && { ...cur, steps: cur.steps.map(s => (s.id === id ? { ...s, ...patch } : s)) },
183  )
184  if (next) showProgress($, next)
185}
186
187// Claude Code puts the plugin's name before its status line itself.
188function showProgress($: EngineInterface, p: RpProgress) {
189  const finished = p.steps.filter(s => s.state !== 'waiting' && s.state !== 'running').length
190  $.ui.status(`attaching ${p.projectName} @ ${p.machineName}… ${finished}/${p.steps.length}`)
191}
192
193// AGENTS.md or CLAUDE.md at the project's root. A CLAUDE.md that only
194// imports AGENTS.md (`@AGENTS.md`) adds nothing, so it counts as none.
195async function loadInstruction($: EngineInterface, p: RpPick, path: string) {
196  const file = await readText($, p, path)
197  const isEmpty =
198    !file ||
199    file.text.trim() === '' ||
200    (path === 'CLAUDE.md' && /^(\s*@(\.\/)?AGENTS\.md\s*)+$/.test(file.text))
201  if (isEmpty) {
202    await setStep($, path, { state: 'none', detail: file ? 'only imports AGENTS.md' : 'none' })
203    return null
204  }
205  const lines = file.text.split('\n').length
206  await setStep($, path, { state: 'done', detail: plural(lines, 'line') })
207  return file
208}
209
210// Each folder (or link to one) in a skills folder that holds a SKILL.md.
211async function loadSkills($: EngineInterface, p: RpPick, dir: string): Promise<RpSkill[]> {
212  let entries: any[]
213  try {
214    const out = await callComputer($, p.machineId, 'list_directory', {
215      project: p.project.id,
216      path: dir,
217    })
218    entries = out.entries ?? []
219  } catch {
220    await setStep($, dir, { state: 'none', detail: 'none' })
221    return []
222  }
223  const folders = entries
224    .filter(e => (e.directory || e.symlink) && !String(e.name).startsWith('.'))
225    .map(e => String(e.name))
226  await setStep($, dir, { detail: `reading ${plural(folders.length, 'skill')}` })
227  const skills = await Promise.all(
228    folders.map(async (folder): Promise<RpSkill | null> => {
229      const path = `${dir}/${folder}/SKILL.md`
230      const file = await readText($, p, path, 40)
231      if (!file) return null
232      const fm = frontMatter(file.text)
233      return { name: fm.name || folder, description: fm.description || '', path }
234    }),
235  )
236  const found = skills.filter((s): s is RpSkill => s !== null)
237  await setStep($, dir, {
238    state: found.length > 0 ? 'done' : 'none',
239    detail: found.length > 0 ? found.map(s => s.name).join(', ') : 'none',
240  })
241  return found
242}
243
244function markerOf(kind: 'attached' | 'detached', p: RpPick): string {
245  return `[rp:${kind} ${p.machineId}/${p.project.id}]`
246}
247
248// The folder's path stays out of the note: it can name the user, and the
249// project argument is all the tools need. Relative paths resolve inside it.
250function attachNote(
251  p: RpPick,
252  files: ProjectFile[],
253  skills: RpSkill[],
254  isLinked: boolean,
255): string {
256  const who = isLinked
257    ? `The person linked this session's folder to the ReadyRig project ` +
258      `"${p.project.name}" on machine "${p.machineName}" (computer_id: ` +
259      `${p.machineId}), so it was attached when the session started`
260    : `The person attached the ReadyRig project "${p.project.name}" on machine ` +
261      `"${p.machineName}" (computer_id: ${p.machineId}) to this session`
262  const parts = [
263    markerOf('attached', p),
264    `${who}, in place of any project attached before. Until they detach it, work in this project ` +
265      `unless they say otherwise: call mcp__readyrig__call_computer_tool with ` +
266      `computer_id "${p.machineId}", pass project: "${p.project.id}" in the tool's ` +
267      `arguments, and use paths relative to the project.`,
268    '## Project instructions',
269  ]
270  const names = INSTRUCTION_FILES.join(' or ')
271  if (files.length > 0) {
272    parts.push(
273      `From ${files.map(f => f.path).join(' and ')} at the project's root. Follow ` +
274        `them for work in this project. A subfolder may hold its own ${names}: ` +
275        `before changing files in a subfolder, look for one; the file nearest to ` +
276        `what you change wins, and what the person asks wins over all of them. ` +
277        `A line \`@path\` imports that file: read it with read_file when it matters.`,
278    )
279    for (const f of files) {
280      const isCut = f.isTruncated || f.text.length > INSTRUCTIONS_MAX
281      parts.push(
282        `<project_file path="${f.path}">\n${f.text.slice(0, INSTRUCTIONS_MAX).trimEnd()}\n` +
283          (isCut ? `[cut short here: read the rest with read_file]\n` : '') +
284          `</project_file>`,
285      )
286    }
287  } else {
288    parts.push(
289      `The project has no ${names} at its root. A subfolder may hold one: ` +
290        `before changing files in a subfolder, look for one and follow it.`,
291    )
292  }
293  parts.push('## Project skills')
294  if (skills.length > 0) {
295    parts.push(
296      `From ${SKILL_DIRS.join(' and ')}. When a task matches a skill's ` +
297        `description, read that whole SKILL.md with read_file on this computer ` +
298        `and project before you start, then follow it. Paths a skill names are ` +
299        `relative to its folder.`,
300      skills
301        .map(s => `- ${s.name}: ${s.description || '(no description)'} (${s.path})`)
302        .join('\n'),
303    )
304  } else {
305    parts.push(`The project has no skills in ${SKILL_DIRS.join(' or ')}.`)
306  }
307  return parts.join('\n\n')
308}
309
310function detachNote(p: RpPick): string {
311  return (
312    `${markerOf('detached', p)}\n` +
313    `The person detached the ReadyRig project "${p.project.name}" on machine ` +
314    `"${p.machineName}". Its instructions and skills no longer apply, and the ` +
315    `session has no attached project: don't work in it unless they ask.`
316  )
317}
318
319async function appendNote($: EngineInterface, text: string) {
320  const out = await $.session.append({
321    message: { type: 'user', content: [{ type: 'text', text }] },
322  })
323  if (out.deny) throw new Error(out.deny)
324}
325
326function summary(a: Pick<RpAttached, 'instructionFiles' | 'skills'>): string {
327  return [...a.instructionFiles, plural(a.skills.length, 'skill')].join(' · ')
328}
329
330function isSame(a: RpPick | null, b: RpPick | null): boolean {
331  return Boolean(a && b && a.machineId === b.machineId && a.project.id === b.project.id)
332}
333
334async function showStatus($: EngineInterface) {
335  const a = await read($, attached)
336  const linked = isSame(a, await read($, link)) ? ' · linked' : ''
337  $.ui.status(a ? `${a.project.name} @ ${a.machineName} · ${summary(a)}${linked}` : undefined)
338}
339
340// Reads the project's instructions and skills with the pane listing each
341// step, then hands them to the model in one note at the end of the conversation.
342async function attach(
343  $: EngineInterface,
344  p: RpPick,
345  isLinked = false,
346): Promise<RpAttached | null> {
347  const start: RpProgress = {
348    machineName: p.machineName,
349    projectName: p.project.name,
350    steps: [
351      ...INSTRUCTION_FILES.map(id => ({ id, label: id, state: 'running' as const })),
352      ...SKILL_DIRS.map(id => ({ id, label: id, state: 'running' as const })),
353      { id: NOTE_STEP, label: 'Add to the conversation', state: 'waiting' },
354    ],
355  }
356  await update($, progress, () => start)
357  showProgress($, start)
358  try {
359    const [files, skillLists] = await Promise.all([
360      Promise.all(INSTRUCTION_FILES.map(path => loadInstruction($, p, path))),
361      Promise.all(SKILL_DIRS.map(dir => loadSkills($, p, dir))),
362    ])
363    const instructions = files.filter((f): f is ProjectFile => f !== null)
364    const skills: RpSkill[] = []
365    for (const s of skillLists.flat()) {
366      if (!skills.some(k => k.name === s.name)) skills.push(s)
367    }
368
369    await setStep($, NOTE_STEP, { state: 'running' })
370    const note = attachNote(p, instructions, skills, isLinked)
371    await appendNote($, note)
372    await setStep($, NOTE_STEP, { state: 'done' })
373
374    const a: RpAttached = { ...p, instructionFiles: instructions.map(f => f.path), skills, note }
375    await update($, attached, () => a)
376    await update($, needsNote, () => false)
377    await update($, progress, () => null)
378    await showStatus($)
379    await $.ui.close({ id: PANE })
380    $.ui.toast(
381      `Attached ${p.project.name} @ ${p.machineName}${isLinked ? ' (linked)' : ''}: ${summary(a)}`,
382    )
383    return a
384  } catch (err) {
385    const message = String((err as Error).message ?? err)
386    await update($, progress, cur => cur && { ...cur, error: message })
387    await setStep($, NOTE_STEP, { state: 'failed' })
388    await showStatus($)
389    return null
390  }
391}
392
393async function detach($: EngineInterface): Promise<string> {
394  const a = await read($, attached)
395  if (!a) return 'No ReadyRig project is attached.'
396  await update($, attached, () => null)
397  await update($, needsNote, () => false)
398  await showStatus($)
399  await appendNote($, detachNote(a))
400  const l = await read($, link)
401  const stays = isSame(a, l) ? ` This folder stays linked to it: /rp unlink stops attaching it.` : ''
402  return `Detached ${a.project.name} @ ${a.machineName}.${stays}`
403}
404
405function slashed(path: string): string {
406  return /[\\/]$/.test(path) ? path : `${path}/`
407}
408
409async function readLinks($: EngineInterface): Promise<Record<string, RpPick>> {
410  const value = await $.store.get(LINKS)
411  return value && typeof value === 'object' ? (value as Record<string, RpPick>) : {}
412}
413
414// The link of the session's project root, or of the nearest folder above it.
415async function findLink($: EngineInterface): Promise<RpLink | null> {
416  const root = await $.session.root()
417  const links = await readLinks($)
418  const folder = Object.keys(links)
419    .filter(f => root === f || slashed(root).startsWith(slashed(f)) || root.startsWith(`${f}\\`))
420    .sort((a, b) => b.length - a.length)[0]
421  const p = folder ? links[folder] : undefined
422  return folder && p ? { ...p, folder } : null
423}
424
425// Links the session's project root to `p`, in place of a link it had.
426async function linkHere($: EngineInterface, p: RpPick): Promise<string> {
427  const folder = await $.session.root()
428  const links = await readLinks($)
429  const pick: RpPick = { machineId: p.machineId, machineName: p.machineName, project: p.project }
430  await $.store.set(LINKS, { ...links, [folder]: pick })
431  await update($, link, () => ({ ...pick, folder }))
432  await showStatus($)
433  return (
434    `Linked ${folder} to ${p.project.name} @ ${p.machineName}: a session started ` +
435    `in this folder, or in one below it, attaches it.`
436  )
437}
438
439async function unlink($: EngineInterface): Promise<string> {
440  const l = await findLink($)
441  if (!l) return 'This folder is not linked to a ReadyRig project.'
442  const { [l.folder]: _, ...rest } = await readLinks($)
443  await $.store.set(LINKS, rest)
444  // A link of a folder above this one may apply now.
445  await update($, link, () => null)
446  const above = await findLink($)
447  await update($, link, () => above)
448  await showStatus($)
449  const a = await read($, attached)
450  const stays = isSame(a, l) ? ' It stays attached to this session: /rp detach detaches it.' : ''
451  return `Unlinked ${l.folder} from ${l.project.name} @ ${l.machineName}.${stays}`
452}
453
454// The machine of a link, with its projects. Until the ReadyRig MCP server
455// connects, the session has no such tool and the call rejects: try again. A
456// denied call or an error answer is final.
457async function linkedMachine($: EngineInterface, l: RpLink): Promise<RpMachine> {
458  let ran: any
459  for (let tries = 1; !ran; tries++) {
460    try {
461      ran = await $.tool.call({ tool: 'mcp__readyrig__list_computers' } as any)
462    } catch (err) {
463      if (tries >= CONNECT_TRIES) throw err
464      await $.clock.sleep(CONNECT_WAIT_MS)
465    }
466  }
467  const c = (answerOf(ran).computers ?? []).find((c: any) => c.id === l.machineId)
468  if (!c) return { ...unknownMachine(l), error: 'not one of your ReadyRig computers' }
469  return loadMachine($, c)
470}
471
472function unknownMachine(l: RpLink): RpMachine {
473  return { id: l.machineId, name: l.machineName, platform: '', online: false, projects: [] }
474}
475
476// A session started in a linked folder attaches the linked project. A reload
477// of the mod leaves an attached project as it is.
478async function attachLinked($: EngineInterface) {
479  const l = await findLink($)
480  await update($, link, () => l)
481  if (!l || (await read($, attached)) || (await read($, progress))) return showStatus($)
482  const failed = (why: string) => {
483    $.ui.status(undefined)
484    $.ui.toast(`Could not attach ${l.project.name} @ ${l.machineName}, linked to this folder: ${why}`)
485  }
486  $.ui.status(`attaching ${l.project.name} @ ${l.machineName} (linked)…`)
487  let m: RpMachine
488  try {
489    m = await linkedMachine($, l)
490  } catch (err) {
491    return failed(String((err as Error).message ?? err))
492  }
493  if (m.error) return failed(m.error)
494  const project = m.projects.find(p => p.id === l.project.id)
495  if (!project) return failed(`the machine has no project ${l.project.name} any more`)
496  const a = await attach($, { machineId: m.id, machineName: m.name, project }, true)
497  if (!a) failed((await read($, progress))?.error ?? 'unknown error')
498  await update($, progress, () => null)
499}
500
501const STEP_ICON: Record<RpStep['state'], string> = {
502  waiting: '·',
503  running: '…',
504  done: '✓',
505  none: '–',
506  failed: '✗',
507}
508
509export const register: Register = on => {
510  // Attaching a linked project runs while Claude Code starts; the first prompt
511  // waits for it, so the model has the note before it answers.
512  let linking: Promise<void> | null = null
513
514  on('session.start', async ($, e, next) => {
515    await $.command.register({
516      name: 'rp',
517      description: 'Attach a ReadyRig project (its AGENTS.md, CLAUDE.md and skills) to this session',
518      argumentHint: '[detach | link | unlink]',
519    })
520    linking = attachLinked($).catch(() => {})
521
522    return next(e)
523  })
524
525  on('command.run', { command: 'rp' }, async ($, e) => {
526    const arg = e.args.trim()
527    if (arg === 'detach' || arg === 'clear') {
528      await $.ui.close({ id: PANE })
529      return { text: await detach($) }
530    }
531    if (arg === 'unlink') return { text: await unlink($) }
532    const current = await read($, attached)
533    if (arg === 'link' && current) return { text: await linkHere($, current) }
534
535    const isLink = arg === 'link'
536    await update($, query, () => '')
537    await update($, progress, () => null)
538    await update($, isLinking, () => isLink)
539    await $.ui.open({
540      id: PANE,
541      title: isLink ? 'Link a ReadyRig project to this folder' : 'ReadyRig projects',
542      focus: true,
543      closeOnEscape: true,
544    })
545    await load($)
546
547    return {
548      text: isLink
549        ? 'Pick a ReadyRig project to attach and link to this folder in the pane (Esc to cancel).'
550        : 'Pick a ReadyRig project to attach in the pane (Esc to cancel).',
551    }
552  })
553
554  // The note is appended once, at the end of the conversation, so the cached
555  // prompt before it stays valid. A compaction of the main conversation or a
556  // /clear drops it: the next prompt puts it back.
557  on('session.compact', async ($, e, next) => {
558    const out = await next(e)
559    if (!e.agentId && e.trigger !== 'precompute' && out.messages) {
560      await update($, needsNote, () => true)
561    }
562    return out
563  })
564
565  on('session.end', async ($, e, next) => {
566    if (e.reason === 'clear') await update($, needsNote, () => true)
567    return next(e)
568  })
569
570  on('prompt.submit', async ($, e, next) => {
571    const isCommand = e.text.trimStart().startsWith('/')
572    // Held under the hook's 10 s budget; a slower attach adds its note when done.
573    if (linking && !isCommand) {
574      await Promise.race([linking, $.clock.sleep(LINK_WAIT_MS)])
575      linking = null
576    }
577    const a = await read($, attached)
578    if (a && (await read($, needsNote)) && !isCommand) {
579      await appendNote($, a.note)
580      await update($, needsNote, () => false)
581    }
582    return next(e)
583  }).catch(($, e, next) => next(e))
584
585  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
586    const elements = $.ui.resolve(e)
587    const { Box, Text, Button } = elements
588    // The mobile app draws no text field yet: it shows the whole list.
589    const Input = 'Input' in elements ? elements.Input : null
590    const list = await read($, machines)
591    const loading = await read($, isLoading)
592    const failed = await read($, error)
593    const current = await read($, attached)
594    const running = await read($, progress)
595    const search = await read($, query)
596    const linked = await read($, link)
597    const shown = filterMachines(list, search)
598
599    if (running) {
600      const labelWidth = Math.max(...running.steps.map(s => s.label.length)) + 2
601      return (
602        <Box flexDirection="column">
603          <Box marginBottom={1}>
604            <Text bold>
605              Attaching {running.projectName} <Text dimColor>@ {running.machineName}</Text>
606            </Text>
607          </Box>
608          {running.steps.map(s => (
609            <Box key={`s-${s.id}`} flexDirection="row" paddingLeft={2}>
610              <Box width={2} flexShrink={0}>
611                <Text color={s.state === 'failed' ? 'red' : s.state === 'done' ? 'green' : undefined}>
612                  {STEP_ICON[s.state]}
613                </Text>
614              </Box>
615              <Box width={labelWidth} flexShrink={0}>
616                <Text dimColor={s.state === 'waiting'}>{s.label}</Text>
617              </Box>
618              <Box flexGrow={1} flexShrink={1}>
619                <Text dimColor wrap="wrap">
620                  {s.detail ?? (s.state === 'running' ? 'reading…' : '')}
621                </Text>
622              </Box>
623            </Box>
624          ))}
625          {running.error && (
626            <Box marginTop={1}>
627              <Text color="red">Could not attach: {running.error} · Esc to close</Text>
628            </Box>
629          )}
630        </Box>
631      )
632    }
633
634    // From /rp link, the project picked is linked once it is attached.
635    const choose = async (m: RpMachine, project: RpProject) => {
636      const a = await attach($, { machineId: m.id, machineName: m.name, project })
637      if (a && (await read($, isLinking))) {
638        await update($, isLinking, () => false)
639        $.ui.toast(await linkHere($, a))
640      }
641    }
642
643    // Enter in the search box picks the first project that matches.
644    const chooseFirst = async (text: string) => {
645      const m = filterMachines(await read($, machines), text)[0]
646      const p = m?.projects[0]
647      if (m && p) await choose(m, p)
648    }
649
650    // One name column for every machine, so the paths line up.
651    const nameWidth = Math.max(0, ...list.flatMap(m => m.projects.map(p => p.name.length)))
652    // Too narrow for name and path side by side: put the path on its own line.
653    const isStacked = (e.props.bodyColumns ?? 80) - (2 + 2 + nameWidth + 2) < 20
654
655    return (
656      <Box flexDirection="column">
657        {Input && (
658          <Box marginBottom={1}>
659            <Input
660              key="search"
661              label="Search: "
662              placeholder="project, path or machine"
663              value={search}
664              submitLabel="attach first"
665              autoFocus
666              onInput={value => update($, query, () => value)}
667              onSubmit={value => chooseFirst(value)}
668            />
669          </Box>
670        )}
671        {loading && <Text dimColor>Loading ReadyRig machines…</Text>}
672        {failed && <Text color="red">Could not list machines: {failed}</Text>}
673        {!loading && !failed && list.length === 0 && (
674          <Text dimColor>No ReadyRig machines found.</Text>
675        )}
676        {!loading && list.length > 0 && shown.length === 0 && (
677          <Text dimColor>No projects match "{search}".</Text>
678        )}
679        {shown.map(m => (
680          <Box key={`m-${m.id}`} flexDirection="column" marginBottom={1}>
681            <Text bold>
682              {m.name} <Text dimColor>({m.platform}{m.online ? '' : ', offline'})</Text>
683            </Text>
684            {m.error && (
685              <Box paddingLeft={2}>
686                <Text dimColor>{m.error}</Text>
687              </Box>
688            )}
689            {!m.error && m.projects.length === 0 && (
690              <Box paddingLeft={2}>
691                <Text dimColor>no projects</Text>
692              </Box>
693            )}
694            {m.projects.map(p => (
695              <Box
696                key={`p-${m.id}-${p.id}`}
697                flexDirection={isStacked ? 'column' : 'row'}
698                paddingLeft={2}
699              >
700                <Box flexDirection="row" flexShrink={0}>
701                  <Box width={2} flexShrink={0}>
702                    <Text>{isSame(current, { machineId: m.id, machineName: m.name, project: p }) ? '●' : ' '}</Text>
703                  </Box>
704                  <Box width={isStacked ? undefined : nameWidth + 2} flexShrink={0}>
705                    <Button
706                      key={`b-${m.id}-${p.id}`}
707                      plain
708                      label={p.name}
709                      onPress={() => choose(m, p)}
710                    />
711                  </Box>
712                </Box>
713                <Box flexGrow={1} flexShrink={1} paddingLeft={isStacked ? 4 : 0}>
714                  <Text dimColor wrap="wrap">{p.path}</Text>
715                </Box>
716              </Box>
717            ))}
718          </Box>
719        ))}
720        {current && (
721          <Box flexDirection="row" flexWrap="wrap">
722            <Text dimColor>Attached: {current.project.name} @ {current.machineName} · </Text>
723            <Button
724              key="detach"
725              plain
726              label="detach"
727              onPress={async () => {
728                await detach($)
729                await $.ui.close({ id: PANE })
730              }}
731            />
732            {!isSame(current, linked) && (
733              <Box key="link-row" flexDirection="row">
734                <Text dimColor> · </Text>
735                <Button
736                  key="link"
737                  plain
738                  label="link to this folder"
739                  onPress={async () => $.ui.toast(await linkHere($, current))}
740                />
741              </Box>
742            )}
743          </Box>
744        )}
745        {linked && (
746          <Box flexDirection="row" flexWrap="wrap">
747            <Text dimColor>
748              Linked: {linked.folder} → {linked.project.name} @ {linked.machineName} ·{' '}
749            </Text>
750            <Button
751              key="unlink"
752              plain
753              label="unlink"
754              onPress={async () => $.ui.toast(await unlink($))}
755            />
756          </Box>
757        )}
758      </Box>
759    )
760  })
761}
762
types/index.d.ts 65 lines
1export type RpProject = { id: string; name: string; path: string }
2
3export type RpMachine = {
4  id: string
5  name: string
6  platform: string
7  online: boolean
8  projects: RpProject[]
9  error?: string
10}
11
12export type RpPick = {
13  machineId: string
14  machineName: string
15  project: RpProject
16}
17
18// A folder on this computer linked to a ReadyRig project: a session started
19// in it, or in a folder below it, attaches the project.
20export type RpLink = RpPick & { folder: string }
21
22// A skill from .agents/skills or .claude/skills; path is project-relative.
23export type RpSkill = { name: string; description: string; path: string }
24
25// The project attached to the session, with the note the model was handed.
26export type RpAttached = RpPick & {
27  instructionFiles: string[]
28  skills: RpSkill[]
29  note: string
30}
31
32// One step of attaching, as the pane lists it while it runs.
33export type RpStep = {
34  id: string
35  label: string
36  state: 'waiting' | 'running' | 'done' | 'none' | 'failed'
37  detail?: string
38}
39
40export type RpProgress = {
41  machineName: string
42  projectName: string
43  steps: RpStep[]
44  error?: string
45}
46
47declare module 'claude-code' {
48  interface PluginState {
49    rp: {
50      machines: RpMachine[]
51      isLoading: boolean
52      error: string | null
53      attached: RpAttached | null
54      // The conversation lost the attached project's note (a compaction, /clear).
55      needsNote: boolean
56      progress: RpProgress | null
57      query: string
58      // The link of the session's folder, or of the nearest folder above it.
59      link: RpLink | null
60      // The pane was opened by /rp link: the project picked is linked too.
61      isLinking: boolean
62    }
63  }
64}
65