/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…

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
mcp__readyrig__list_computers and mcp__readyrig__call_computer_tool are available. /mcp shows whether it is connected.offline, without projects.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
| Command | What it does |
|---|---|
/rp | Opens 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 detach | Detaches the project (/rp clear does the same). The pane also has a detach button while a project is attached. |
/rp link | Links 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 unlink | Removes 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:
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.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.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.
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.
/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.
| File | Purpose |
|---|---|
.claude-plugin/plugin.json | Manifest |
hooks/hooks.json | Points Claude Code at the hooks module |
hooks/register.tsx | The /rp command, the pane, attaching and detaching, linking folders, and the prompt hook that restores the note |
types/index.d.ts | Types for the values the mod keeps in session state |
tests/rp.test.ts | Attaches projects using fake ReadyRig answers, then checks the note, the progress steps, search, re-adding the note after compaction, detach, and linking folders |
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.
hooks/register.tsx 762 lines1import { 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}
762types/index.d.ts 65 lines1export 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