Lets a session report on its own Remote Control and turn it on: it connects only when the session reads as not connected, so it never toggles a live connection…

Lets a session report on its own Remote Control, the link that lets you follow and steer it from a phone or a browser, and turn it on. Part of claude-code-self-tools.
remote_control (Claude sees mcp__self-remote-control__remote_control):
action (required): status or connect.status answers with whether this session is connected, the value of the /config setting for all sessions (remoteControl), and the clients attached now.connect connects this session once the turn ends, if it isn't connected./remote-control is a toggle: run while connected, it disconnects. So connect runs it only when the session reads as not connected, and reads again right before it runs. If the session connected in the meantime, nothing runs./remote-control command's own description, which Claude Code works out from the connection each time it lists the command: Disconnect Remote Control while connected, Control this session from your phone or claude.ai/code while not. Any other description reads as unknown, and connect refuses rather than risk the toggle. The attached clients don't tell you either: a connected session with no phone open shows only the terminal.connect says so./plugin marketplace add moefingers/claude-code-self-tools
/plugin install self-remote-control@claude-code-self-toolshooks/register.ts 190 lines1// A session reads, and can turn on, its own Remote Control: the link that lets the user follow and
2// steer it from a phone or a browser. `status` reports; `connect` runs the remote-control command,
3// once the turn ends, and only when this session is not connected. That command is a toggle: run
4// while connected, it disconnects. So `connect` runs it only on a reading it recognises as "not
5// connected", taken again right before it runs, and refuses on any reading it doesn't recognise.
6//
7// The reading: the remote-control command's own description, which the engine works out from this
8// session's connection each time it lists the command. It is the one place the API says whether
9// this session is connected. The attached surfaces say which clients draw the session now (a
10// connected session with no phone attached shows the terminal alone), session.attach and
11// session.detach carry those same clients, and the `remoteControl` setting is the default for all
12// sessions; none of them says whether this session is connected.
13//
14// Only the main conversation acts this way, never a subagent. An interrupted turn cancels a
15// queued connect.
16import type { EngineInterface, Register } from 'claude-code'
17
18const connectQueued = { plugin: 'self-remote-control', key: 'connectQueued' } as const
19
20/** A beat after the turn's end, so the turn is no longer running when the command starts. */
21const SETTLE_MS = 500
22/** How long after the command to look again, for the toast that says whether it took. */
23const CHECK_MS = 3000
24
25/** The remote-control command's description while this session is connected, and while it is not. */
26const CONNECTED = 'Disconnect Remote Control'
27const NOT_CONNECTED = 'Control this session from your phone or claude.ai/code'
28
29const DESCRIPTION = [
30 'Reports on, or turns on, Remote Control for this session: the link that lets the user follow and steer the session from a phone or a browser.',
31 '`status` says whether this session is connected now, what the Remote Control for all sessions setting holds, and which clients are attached.',
32 '`connect` connects this session once the current turn ends, only if it is not connected then; it never disconnects.',
33 'Call `status` when the user asks whether they can reach this session remotely, and `connect` when they ask to be able to, such as before stepping away.',
34 'Not for disconnecting, which is the user\'s to do with /remote-control.',
35 'After `connect`, end the turn; it runs once your answer is shown.',
36].join(' ')
37
38const SUBAGENT =
39 "A subagent can't read or change the session's Remote Control. Finish your piece and return its result; the main session can connect."
40
41type Reading =
42 | { state: 'connected' | 'not connected' }
43 | { state: 'unavailable' }
44 | { state: 'unknown'; description: string }
45
46/** Why a call was refused, as one line. */
47function why(err: unknown): string {
48 const said = (err instanceof Error ? err.message : String(err)).trim()
49 return said === '' ? 'no reason given' : said
50}
51
52/** Tells the model on its next turn, in a row it reads and the person doesn't see as typed. */
53async function tellModel($: EngineInterface, note: string): Promise<void> {
54 try {
55 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
56 } catch {
57 // The toast has told the person; there is no other way to reach the model.
58 }
59}
60
61/** Whether this session's Remote Control is connected, from the remote-control command's description. */
62async function read($: EngineInterface): Promise<Reading> {
63 const command = (await $.command.list()).find(c => c.name === 'remote-control' && c.source === 'builtin')
64 if (command === undefined) return { state: 'unavailable' }
65 if (command.description === CONNECTED) return { state: 'connected' }
66 if (command.description === NOT_CONNECTED) return { state: 'not connected' }
67 return { state: 'unknown', description: command.description }
68}
69
70/** Why `connect` won't run the command on this reading. */
71function refusal(reading: Reading): string {
72 if (reading.state === 'unavailable') {
73 return "This session has no remote-control command (it needs a claude.ai subscription, and a policy can turn it off), so there is nothing to connect."
74 }
75 if (reading.state === 'unknown') {
76 return `Can't tell whether Remote Control is connected: the remote-control command reads "${reading.description}", which this plugin doesn't recognise. Run while connected, that command disconnects, so it isn't run.`
77 }
78 return 'Remote Control is already connected; nothing to do.'
79}
80
81async function status($: EngineInterface): Promise<string> {
82 const [reading, rows, surfaces] = await Promise.all([read($), $.config.list(), $.session.surfaces()])
83 const connection =
84 reading.state === 'unavailable'
85 ? 'Remote Control: unavailable in this session (no remote-control command).'
86 : reading.state === 'unknown'
87 ? `Remote Control: unknown (the remote-control command reads "${reading.description}").`
88 : `Remote Control: ${reading.state}.`
89 const row = rows.find(r => r.key === 'remoteControl')
90 const setting =
91 row === undefined
92 ? 'The Remote Control for all sessions setting is not listed in /config.'
93 : `Remote Control for all sessions (/config remoteControl): ${String(row.value)}.`
94 const attached = `Attached clients: ${surfaces.length === 0 ? 'none' : surfaces.join(', ')}.`
95 return `${connection} ${setting} ${attached}`
96}
97
98/** At the turn's end: connect, only on a fresh "not connected" reading, then say whether it took. */
99async function connectNow($: EngineInterface): Promise<void> {
100 $.ui.status(undefined)
101 let reading: Reading
102 try {
103 reading = await read($)
104 } catch (err) {
105 reading = { state: 'unknown', description: `no reading: ${why(err)}` }
106 }
107 if (reading.state === 'connected') {
108 $.ui.toast('Remote Control was already connected; nothing ran.')
109 return
110 }
111 if (reading.state !== 'not connected') {
112 $.ui.toast(`Remote Control not connected: ${refusal(reading)}`)
113 await tellModel($, `The Remote Control connect you queued did not run. ${refusal(reading)}`)
114 return
115 }
116 try {
117 await $.command.run({ command: 'remote-control' })
118 } catch (err) {
119 $.ui.toast(`Remote Control didn't connect: ${why(err)}`)
120 await tellModel($, `The Remote Control connect you queued didn't run: ${why(err)}`)
121 return
122 }
123 $.clock.after(CHECK_MS, () => void checkConnected($))
124}
125
126async function checkConnected($: EngineInterface): Promise<void> {
127 const reading = await read($)
128 if (reading.state === 'connected') {
129 $.ui.toast('Remote Control connected.')
130 return
131 }
132 $.ui.toast('Remote Control: the command ran, but the session does not read as connected yet.')
133 await tellModel($, 'The remote-control command ran, but this session did not read as connected a few seconds later; check with remote_control status.')
134}
135
136export const register: Register = on => {
137 on('session.start', async ($, e, next) => {
138 const started = await next(e)
139 await $.tool.register({
140 name: 'remote_control',
141 description: DESCRIPTION,
142 inputSchema: {
143 type: 'object',
144 properties: {
145 action: {
146 type: 'string',
147 enum: ['status', 'connect'],
148 description: '`status` to report; `connect` to connect this session once the turn ends, if it is not connected.',
149 },
150 },
151 required: ['action'],
152 additionalProperties: false,
153 },
154 isDeferred: false,
155 })
156 return started
157 })
158
159 on('tool.call', { tool: 'mcp__self-remote-control__remote_control' }, async ($, e) => {
160 if (e.agentId !== undefined) return { deny: SUBAGENT }
161 const action = (e as { action?: unknown }).action
162 if (action === 'status') return { result: await status($) }
163 if (action !== 'connect') return { deny: '`action` is `status` or `connect`.' }
164 const reading = await read($)
165 if (reading.state !== 'not connected') {
166 return reading.state === 'connected' ? { result: refusal(reading) } : { deny: refusal(reading) }
167 }
168 await $.state.set(connectQueued, true)
169 $.ui.status('Remote Control connects when this turn ends')
170 return {
171 result: 'Queued: Remote Control connects once this turn ends (it reads not connected now). The session checks again right before, and runs nothing if it is connected by then.',
172 }
173 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'self-remote-control: the call failed; nothing is queued.' }))
174
175 on('turn.complete', async ($, e, next) => {
176 const done = await next(e)
177 if (e.agentId !== undefined) return done
178 const { value } = await $.state.get(connectQueued)
179 if (!value) return done
180 await $.state.set(connectQueued, false)
181 if (e.isAborted || e.reason !== 'answer') {
182 $.ui.status(undefined)
183 $.ui.toast("Remote Control connect cancelled: the turn didn't finish.")
184 return done
185 }
186 $.clock.after(SETTLE_MS, () => void connectNow($))
187 return done
188 })
189}
190types/index.d.ts 9 lines1/** Whether this turn asked to connect Remote Control once it ends. */
2export type ConnectQueued = boolean
3
4declare module 'claude-code' {
5 interface PluginState {
6 'self-remote-control': { connectQueued: ConnectQueued }
7 }
8}
9