Restarts this session's Remote Control bridge when agentctl asks after a live account swap, and reports the bridge's presence with /agentctl-rc.

A Claude Code mod that restarts a session's Remote Control bridge after agentctl claude use --live <id> --restart-remote-control (or --undo --restart-remote-control) has changed the account under it. Claude Code drops the bridge by itself when the credential changes; this mod starts the new one inside the same process, with the same session id and transcript, without typing into the terminal. It also works on its own through /agentctl-rc.
Typed at the prompt of a terminal Claude Code session:
/plugin install agentctl-remote-control --marketplace zchee/agentctl
Answer y to add the zchee/agentctl marketplace, then pick a scope (the user scope makes it load in every session started afterwards). Each session that should be reconnected by agentctl needs the mod loaded.
/agentctl-rc, which says why./usr/bin/stat -f '%u %Lp %HT'; any other answer (a Linux stat among them) keeps it inactive.CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL, answers unavailable and runs nothing./agentctl-rc prints whether this session's registry record shows a Remote Control bridge, how many distinct bridges the mod has seen it hold (its generation), the surfaces the session draws on, the Claude Code release, and whether each environment variable the session's keychain item name derives from is set. It never prints a bridge id, a URL or a directory spelling./agentctl-rc reconnect asks for the same guarded reconnect agentctl asks for and answers requested. The result appears as a toast when it is final: reconnected, already connected, unavailable, not confirmed, or expired.agentctl writes <id>.request.json (mode 0600, written to a temporary name and renamed) into <configHome>/agentctl/remote-control/<pid>/, a 0700 directory it creates under the session's own configuration home, where <pid> is the session's process id. Every 500 ms the mod lists that directory, checks each request (name, real path, owner uid and mode 600 through /usr/bin/stat, contract version, action, expiry, id, subject), writes <id>.ack.json once, and writes <id>.response.json once the answer is final. A status request is answered at once with bridge presence and generation, surfaces, the release, and the provenance above. A reconnect request runs /remote-control only when the session is idle and its registry record, read on the same tick, shows no bridge; it then watches the record for up to 30 seconds for a new bridge. Requests live in the session's $.state, so a hot reload of the mod neither answers one twice nor runs /remote-control twice. A refused request is remembered in $.state too, in a list of at most 256 entries, so a file answered busy while the mod's table was full is not admitted later, after a reload included. The bound has one residual: when 256 unexpired refusals are held, the newest is still answered and recorded by forgetting the oldest, and a file of that forgotten id still in the directory is examined again; it can be answered a second time and, if it was refused only as busy and has not expired, admitted. The mod creates no directory, deletes nothing, and never reads the *.key files beside the registry records. The full contract is in docs/research/remote-control-mod.md at the repository root.
The mod issues /remote-control only from a poll tick on which no turn is running and the registry record shows no bridge, so the command runs within milliseconds of that observation. A person who types /remote-control in that same instant can still start a bridge first; the mod's command then finds a connected bridge and Claude Code opens its Remote Control dialog (Disconnect, Show QR code, Continue), which Escape dismisses without disconnecting. The mod cannot close this window: Claude Code offers no way to issue the command conditionally.
claude plugin validate --strict plugins/remote-control
claude plugin test plugins/remote-control
tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes when it loads the mod from a folder it hot-reloads (for example claude --plugin-dir plugins/remote-control); that directory is not committed.
hooks/register.ts 777 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type {
4 AgentctlRcAck,
5 AgentctlRcAction,
6 AgentctlRcEntry,
7 AgentctlRcProvenance,
8 AgentctlRcRejected,
9 AgentctlRcRejectReason,
10 AgentctlRcResponse,
11 AgentctlRcResult,
12} from '../types'
13
14// Claude Code 2.1.287 is the first release with the hooks this mod stands on.
15const FLOOR = [2, 1, 287] as const
16const POLL_MS = 500
17// How long an issued `remote-control` is watched for a new bridge id.
18const OBSERVE_MS = 30_000
19// The lifetime of a reconnect asked for with `/agentctl-rc reconnect`, the
20// same as agentctl gives its own reconnect requests.
21const COMMAND_LIFETIME_MS = 75_000
22// The longest lifetime a request may ask for, eight times agentctl's longest.
23// A refusal of a file whose body was not read is remembered this long, so a
24// request file still lying there afterwards is refused again, as expired when
25// its body is readable, and is never admitted.
26const MAX_LIFETIME_MS = 600_000
27// Bounds on the table: entries not yet answered, and entries of any phase.
28const MAX_PENDING = 64
29const MAX_ENTRIES = 1024
30// Bound on the list of refused request files.
31const MAX_REJECTED = 256
32// agentctl reads registry records with the same bound.
33const MAX_RECORD_BYTES = 64 * 1024
34const MAX_REQUEST_BYTES = 64 * 1024
35const REQUEST_NAME = /^([0-9a-f]{32})\.request\.json$/
36const ID_COMMAND = '/usr/bin/id'
37const STAT_COMMAND = '/usr/bin/stat'
38// The darwin `stat -f '%u %Lp %HT'` answer: owner uid, permission bits in
39// octal, file type in words. A non-darwin `stat` cannot print this shape.
40const STAT_SHAPE = /^(\d+) ([0-7]{1,4}) ([A-Za-z ]+)$/
41
42type Timer = ReturnType<EngineInterface['clock']['every']>
43type Table = Record<string, AgentctlRcEntry>
44
45/** What the mod established about its own process at session start. */
46type Context = {
47 pid: number
48 configHome: string
49 recordPath: string
50 transportDir: string
51 uid: number
52 versionBase: string
53}
54
55/**
56 * The registry record as the mod uses it. The bridge id itself never leaves
57 * `readRecord`: only a digest, compared to tell one bridge from the next.
58 */
59type Record_ = {
60 pid: number | undefined
61 sessionId: string | undefined
62 status: string | undefined
63 messagingSocketPath: string | undefined
64 bridgeMark: string | undefined
65}
66
67type Bridge = { present: boolean; generation: number }
68
69/** What one poll reads once and every entry of that poll shares. */
70type Inputs = {
71 record: Record_ | undefined
72 bridge: Bridge
73 isListed: () => Promise<boolean>
74 isAuthorized: () => Promise<boolean>
75 surfaces: () => Promise<string[]>
76 provenance: () => Promise<AgentctlRcProvenance>
77}
78
79// Module variables are rebuilt by every session.start, which is also what a
80// hot reload re-fires; everything a request needs to survive a reload lives
81// in $.state instead.
82let ticker: Timer | undefined
83let context: Context | undefined
84let refusal: string | undefined
85let turns = new Set<string>()
86let isPolling = false
87let bridgeMark: string | undefined
88let sawAbsent = false
89let queue: Promise<unknown> = Promise.resolve()
90
91/** Replaces Remote Control URLs and bridge ids in text bound for a log or an answer. */
92export function redact(text: string): string {
93 return text
94 .replace(/https?:\/\/\S+/g, '[URL redacted]')
95 .replace(/\b(?:session|cse|bridge)_[A-Za-z0-9_-]+/g, '[bridge id redacted]')
96}
97
98/** Reports whether a release base such as `2.1.296` or `2.1.296-dev` is at or above the floor. */
99export function meetsFloor(base: string | undefined): boolean {
100 const match = /^(\d+)\.(\d+)\.(\d+)/.exec(base ?? '')
101 if (match === null) return false
102 const parts = [Number(match[1]), Number(match[2]), Number(match[3])]
103 for (let i = 0; i < FLOOR.length; i++) {
104 const have = parts[i] ?? 0
105 const want = FLOOR[i] ?? 0
106 if (have !== want) return have > want
107 }
108 return true
109}
110
111// Joins a name onto a directory without cleaning the directory's spelling,
112// exactly as agentctl builds the same paths, so both sides name one file.
113function joinPath(dir: string, name: string): string {
114 if (dir === '') return name
115 return dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`
116}
117
118// The configuration home as Claude Code and agentctl derive it: a non-empty
119// CLAUDE_CONFIG_DIR as spelled, else HOME's .claude, NFC-normalized.
120function configHomeOf(home: string | undefined, configDir: string | undefined): string | undefined {
121 if (configDir !== undefined && configDir !== '') return configDir.normalize('NFC')
122 if (home === undefined || home === '') return undefined
123 return joinPath(home, '.claude').normalize('NFC')
124}
125
126function isNonEmpty(value: string | undefined): boolean {
127 return value !== undefined && value !== ''
128}
129
130function log($: EngineInterface, text: string): void {
131 $.ui.log(redact(text), { to: 'debug' })
132}
133
134// Runs table mutations one at a time, so the poll, the command and a
135// rejected run never interleave a read and a write of $.state.
136function serial<T>(work: () => Promise<T>): Promise<T> {
137 const run = queue.then(work, work)
138 queue = run.catch(() => undefined)
139 return run
140}
141
142async function sha256(text: string): Promise<string> {
143 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
144 return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
145}
146
147function newId(): string {
148 return Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('')
149}
150
151function isObject(value: unknown): value is Record<string, unknown> {
152 return typeof value === 'object' && value !== null && !Array.isArray(value)
153}
154
155function stringOf(value: unknown): string | undefined {
156 return typeof value === 'string' ? value : undefined
157}
158
159async function readRecord($: EngineInterface, path: string): Promise<Record_ | undefined> {
160 const stat = await $.fs.stat(path).catch(() => undefined)
161 if (stat === undefined || stat.kind !== 'file' || stat.isLink || stat.size > MAX_RECORD_BYTES) return undefined
162 const text = await $.fs.read(path).catch(() => undefined)
163 if (typeof text !== 'string') return undefined
164 let raw: unknown
165 try {
166 raw = JSON.parse(text)
167 } catch {
168 // The parser's message can quote the record, bridge id included, so it is dropped.
169 return undefined
170 }
171 if (!isObject(raw)) return undefined
172 const bridge = stringOf(raw.bridgeSessionId)
173 return {
174 pid: typeof raw.pid === 'number' ? raw.pid : undefined,
175 sessionId: stringOf(raw.sessionId),
176 status: stringOf(raw.status),
177 messagingSocketPath: stringOf(raw.messagingSocketPath),
178 bridgeMark: bridge === undefined || bridge === '' ? undefined : await sha256(bridge),
179 }
180}
181
182// Counts distinct bridges: a new digest, or a bridge seen after an absence,
183// is a new generation. After a reload the first bridge seen is taken as the
184// one already counted unless the mod saw it absent first.
185async function observeBridge($: EngineInterface, record: Record_ | undefined): Promise<Bridge> {
186 const held = await $.state.get({ plugin: 'agentctl-remote-control', key: 'generation' })
187 let generation = held.value ?? 0
188 const mark = record?.bridgeMark
189 if (mark === undefined) {
190 if (record !== undefined) sawAbsent = true
191 return { present: false, generation }
192 }
193 if (mark !== bridgeMark) {
194 const isNew = bridgeMark !== undefined || generation === 0 || sawAbsent
195 bridgeMark = mark
196 sawAbsent = false
197 if (isNew) {
198 generation += 1
199 await $.state.set({ plugin: 'agentctl-remote-control', key: 'generation' }, generation)
200 }
201 }
202 return { present: true, generation }
203}
204
205async function provenanceOf($: EngineInterface): Promise<AgentctlRcProvenance> {
206 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
207 const secureStorageDir = await $.env.get('CLAUDE_SECURESTORAGE_CONFIG_DIR')
208 return {
209 home: (await $.env.get('HOME')) ?? '',
210 configDir: { set: configDir !== undefined, value: configDir ?? '' },
211 secureStorageDir: { set: secureStorageDir !== undefined, value: secureStorageDir ?? '' },
212 oauthTokenSet: isNonEmpty(await $.env.get('CLAUDE_CODE_OAUTH_TOKEN')),
213 apiKeySet: isNonEmpty(await $.env.get('ANTHROPIC_API_KEY')),
214 baseUrlSet: isNonEmpty(await $.env.get('ANTHROPIC_BASE_URL')),
215 authorized: (await $.session.authorize()) !== null,
216 }
217}
218
219function once<T>(work: () => Promise<T>): () => Promise<T> {
220 let held: Promise<T> | undefined
221 return () => (held ??= work())
222}
223
224async function inputsOf($: EngineInterface, ctx: Context): Promise<Inputs> {
225 const record = await readRecord($, ctx.recordPath)
226 const bridge = await observeBridge($, record)
227 return {
228 record,
229 bridge,
230 isListed: once(async () => (await $.command.list()).some((c) => c.name === 'remote-control')),
231 isAuthorized: once(async () => (await $.session.authorize()) !== null),
232 surfaces: once(async () => [...(await $.session.surfaces())]),
233 provenance: once(() => provenanceOf($)),
234 }
235}
236
237/** The owner uid, mode and type `/usr/bin/stat` reports for a path, or undefined when it answers anything else. */
238async function hostStat($: EngineInterface, path: string): Promise<{ uid: number; mode: string; type: string } | undefined> {
239 const ran = await $.process.run([STAT_COMMAND, '-f', '%u %Lp %HT', path], { timeoutMs: 5_000 }).catch(() => undefined)
240 if (ran === undefined || ran.exitCode !== 0) return undefined
241 const match = STAT_SHAPE.exec(ran.stdout.replace(/\n$/, ''))
242 if (match === null) return undefined
243 return { uid: Number(match[1]), mode: match[2] ?? '', type: match[3] ?? '' }
244}
245
246async function ownUid($: EngineInterface): Promise<number | undefined> {
247 const held = await $.state.get({ plugin: 'agentctl-remote-control', key: 'uid' })
248 if (typeof held.value === 'number') return held.value
249 const ran = await $.process.run([ID_COMMAND, '-u'], { timeoutMs: 5_000 }).catch(() => undefined)
250 if (ran === undefined || ran.exitCode !== 0 || !/^\d+\n?$/.test(ran.stdout)) return undefined
251 const uid = Number(ran.stdout.trim())
252 await $.state.set({ plugin: 'agentctl-remote-control', key: 'uid' }, uid)
253 return uid
254}
255
256// Establishes the process's own identity and the platform, or says why the
257// mod must stay inactive in this session.
258async function establish($: EngineInterface): Promise<Context | string> {
259 const version = await $.session.version()
260 if (!meetsFloor(version.base)) return `Claude Code ${version.base ?? version.version} is older than 2.1.287`
261 const socket = await $.env.get('CLAUDE_CODE_MESSAGING_SOCKET')
262 const pidMatch = /\/(\d+)\.sock$/.exec(socket ?? '')
263 if (socket === undefined || pidMatch === null) return 'the session has no messaging socket naming its process'
264 const pid = Number(pidMatch[1])
265 const configHome = configHomeOf(await $.env.get('HOME'), await $.env.get('CLAUDE_CONFIG_DIR'))
266 if (configHome === undefined) return 'neither CLAUDE_CONFIG_DIR nor HOME is set'
267 const recordPath = joinPath(joinPath(configHome, 'sessions'), `${pid}.json`)
268 const record = await readRecord($, recordPath)
269 if (record === undefined || record.pid !== pid || record.messagingSocketPath !== socket) {
270 return 'the session registry record does not match this process'
271 }
272 const uid = await ownUid($)
273 if (uid === undefined) return '/usr/bin/id -u did not answer a uid'
274 const probe = await hostStat($, recordPath)
275 if (probe === undefined || probe.uid !== uid || probe.type !== 'Regular File') {
276 return 'the host stat probe did not answer the macOS shape for the registry record'
277 }
278 const transportDir = joinPath(joinPath(joinPath(configHome, 'agentctl'), 'remote-control'), String(pid))
279 return { pid, configHome, recordPath, transportDir, uid, versionBase: version.base ?? '' }
280}
281
282async function loadTable($: EngineInterface): Promise<Table> {
283 const held = await $.state.get({ plugin: 'agentctl-remote-control', key: 'requests' })
284 return { ...(held.value ?? {}) }
285}
286
287async function save($: EngineInterface, table: Table): Promise<void> {
288 await $.state.set({ plugin: 'agentctl-remote-control', key: 'requests' }, table)
289}
290
291// Drops published entries whose expiry has passed. An entry whose expiry has
292// not passed is kept whatever its phase, because its request file may still
293// lie in the directory and would otherwise be admitted, and run, a second
294// time; once it has passed, such a file can only be refused again. An
295// entry not yet published is kept until its answer is delivered.
296function evict(table: Table, now: number): boolean {
297 let changed = false
298 for (const [id, entry] of Object.entries(table)) {
299 if (now < entry.expiresAt || entry.phase !== 'published') continue
300 delete table[id]
301 changed = true
302 }
303 return changed
304}
305
306function pendingCount(table: Table): number {
307 return Object.values(table).filter((entry) => entry.phase !== 'published').length
308}
309
310function isFull(table: Table): boolean {
311 return pendingCount(table) >= MAX_PENDING || Object.keys(table).length >= MAX_ENTRIES
312}
313
314function isBusy(record: Record_ | undefined): boolean {
315 return turns.size > 0 || (record?.status !== undefined && record.status !== 'idle')
316}
317
318async function responseText(inputs: Inputs, ctx: Context, id: string, action: AgentctlRcAction, result: AgentctlRcResult, now: number, reason?: string): Promise<string> {
319 const response: AgentctlRcResponse = {
320 v: 1,
321 id,
322 action,
323 result,
324 answeredAt: now,
325 bridge: { present: inputs.bridge.present, generation: inputs.bridge.generation },
326 surfaces: await inputs.surfaces(),
327 version: ctx.versionBase,
328 remoteControlListed: await inputs.isListed(),
329 provenance: await inputs.provenance(),
330 }
331 if (reason !== undefined) response.reason = reason
332 return JSON.stringify(response)
333}
334
335function toastText(result: AgentctlRcResult, reason: string | undefined): string {
336 switch (result) {
337 case 'reconnected':
338 return 'Remote Control reconnected.'
339 case 'already_connected':
340 return 'Remote Control is already connected; nothing was run.'
341 case 'unavailable':
342 return `Remote Control is unavailable in this session (${reason ?? 'unknown'}); nothing was run.`
343 case 'not_confirmed':
344 return `Remote Control was started, but no new bridge was seen (${reason ?? 'unknown'}).`
345 case 'expired':
346 return 'The reconnect expired before the session was idle; nothing was run.'
347 case 'cancelled':
348 return 'The reconnect was cancelled; nothing was run.'
349 case 'ok':
350 return 'Remote Control status answered.'
351 }
352}
353
354// Writes into the transport directory only when it still exists, because
355// $.fs.write would create it and the directory is agentctl's to create.
356async function writeTransport($: EngineInterface, ctx: Context, name: string, text: string): Promise<boolean> {
357 if (!(await $.fs.exists(ctx.transportDir))) return false
358 await $.fs.write(joinPath(ctx.transportDir, name), text)
359 return true
360}
361
362// A delivery counts as published only once the file was written; while the
363// directory is missing it stays pending and the same text is tried again.
364async function deliverAck($: EngineInterface, ctx: Context, table: Table, id: string, entry: AgentctlRcEntry): Promise<void> {
365 if (entry.ack === undefined || entry.ack.published) return
366 if (!(await writeTransport($, ctx, `${id}.ack.json`, entry.ack.text))) return
367 entry.ack.published = true
368 await save($, table)
369}
370
371async function deliverFinal($: EngineInterface, ctx: Context, table: Table, id: string, entry: AgentctlRcEntry): Promise<void> {
372 const terminal = entry.terminal
373 if (terminal === undefined) {
374 // A rejection has only its acknowledgement to deliver.
375 if (entry.ack !== undefined && !entry.ack.published) return
376 entry.phase = 'published'
377 await save($, table)
378 return
379 }
380 if (!terminal.published) {
381 if (entry.origin === 'file') {
382 if (!(await writeTransport($, ctx, `${id}.response.json`, terminal.response))) return
383 } else {
384 const answer = JSON.parse(terminal.response) as AgentctlRcResponse
385 $.ui.toast(toastText(answer.result, answer.reason))
386 }
387 }
388 terminal.published = true
389 entry.phase = 'published'
390 await save($, table)
391}
392
393// Stores the final answer before delivering it, so a reload in between
394// delivers the identical text instead of computing a new one.
395async function finish($: EngineInterface, ctx: Context, table: Table, inputs: Inputs, id: string, entry: AgentctlRcEntry, result: AgentctlRcResult, now: number, reason?: string): Promise<void> {
396 entry.phase = 'final'
397 entry.terminal = { response: await responseText(inputs, ctx, id, entry.action, result, now, reason), published: false }
398 await save($, table)
399 log($, `request ${id}: ${result}${reason === undefined ? '' : ` (${reason})`}`)
400 await deliverFinal($, ctx, table, id, entry)
401}
402
403async function onRunRejected($: EngineInterface, id: string): Promise<void> {
404 const ctx = context
405 if (ctx === undefined) return
406 const table = await loadTable($)
407 const entry = table[id]
408 if (entry?.phase !== 'running') return
409 await finish($, ctx, table, await inputsOf($, ctx), id, entry, 'unavailable', await $.clock.now(), 'run_rejected')
410}
411
412// The rule list for a reconnect that has not been run yet, in the order the
413// transport contract fixes; `remote-control` is issued only from an idle
414// session whose record, read this tick, shows no bridge.
415async function applyRules($: EngineInterface, ctx: Context, table: Table, inputs: Inputs, id: string, entry: AgentctlRcEntry, now: number): Promise<void> {
416 if (now >= entry.expiresAt) return finish($, ctx, table, inputs, id, entry, 'expired', now)
417 if (!(await inputs.isListed())) return finish($, ctx, table, inputs, id, entry, 'unavailable', now, 'command_not_listed')
418 if (!(await inputs.isAuthorized())) return finish($, ctx, table, inputs, id, entry, 'unavailable', now, 'not_authorized')
419 if (inputs.bridge.present) return finish($, ctx, table, inputs, id, entry, 'already_connected', now)
420 const record = inputs.record
421 if (record === undefined || isBusy(record)) {
422 // An unreadable record is treated like a running turn: nothing is
423 // issued without seeing the bridge absent, and expiry ends the wait.
424 if (entry.phase !== 'waiting_idle') {
425 entry.phase = 'waiting_idle'
426 await save($, table)
427 }
428 return
429 }
430 entry.phase = 'running'
431 entry.runStartedAt = now
432 entry.observeUntil = now + OBSERVE_MS
433 if (record.sessionId !== undefined) entry.runSessionId = record.sessionId
434 await save($, table)
435 log($, `request ${id}: running remote-control`)
436 // Not awaited: the command's own text can carry the bridge URL and is
437 // never read; the registry record is what confirms the bridge.
438 void $.command.run({ command: 'remote-control' }).catch(() => serial(() => onRunRejected($, id)))
439}
440
441// The one rule for a run already issued: it is observed, never issued again.
442async function observeRun($: EngineInterface, ctx: Context, table: Table, inputs: Inputs, id: string, entry: AgentctlRcEntry, now: number): Promise<void> {
443 const record = inputs.record
444 if (record === undefined || record.pid !== ctx.pid || record.sessionId !== entry.runSessionId) {
445 return finish($, ctx, table, inputs, id, entry, 'not_confirmed', now, 'record_changed')
446 }
447 if (inputs.bridge.present) return finish($, ctx, table, inputs, id, entry, 'reconnected', now)
448 if (now >= (entry.observeUntil ?? 0)) return finish($, ctx, table, inputs, id, entry, 'not_confirmed', now, 'window_elapsed')
449}
450
451async function step($: EngineInterface, ctx: Context, table: Table, inputs: Inputs, id: string, entry: AgentctlRcEntry, now: number): Promise<void> {
452 await deliverAck($, ctx, table, id, entry)
453 switch (entry.phase) {
454 case 'published':
455 return
456 case 'final':
457 return deliverFinal($, ctx, table, id, entry)
458 case 'running':
459 return observeRun($, ctx, table, inputs, id, entry, now)
460 case 'accepted':
461 case 'waiting_idle':
462 if (entry.action === 'status') return finish($, ctx, table, inputs, id, entry, 'ok', now)
463 return applyRules($, ctx, table, inputs, id, entry, now)
464 }
465}
466
467// The transport directory as it really lies, or undefined when it is
468// missing, not a directory, or resolves anywhere but where agentctl makes it.
469async function transportReal($: EngineInterface, ctx: Context): Promise<string | undefined> {
470 if (!(await $.fs.exists(ctx.transportDir))) return undefined
471 const dir = await $.fs.stat(ctx.transportDir, { resolve: true }).catch(() => undefined)
472 const home = await $.fs.stat(ctx.configHome, { resolve: true }).catch(() => undefined)
473 if (dir?.realPath === undefined || home?.realPath === undefined || dir.kind !== 'dir' || dir.isLink) return undefined
474 const expected = joinPath(joinPath(joinPath(home.realPath, 'agentctl'), 'remote-control'), String(ctx.pid))
475 return dir.realPath === expected ? dir.realPath : undefined
476}
477
478// The acknowledgement of a refused file. `action` is empty when the body was
479// not read or cannot be trusted, and the request's own action otherwise.
480function rejectedAck(id: string, action: string, reason: AgentctlRcRejectReason, now: number): string {
481 const ack: AgentctlRcAck = { v: 1, id, action, state: 'rejected', reason, answeredAt: now }
482 return JSON.stringify(ack)
483}
484
485/** Why one file is refused, the action its acknowledgement names, and how long the refusal is remembered. */
486type Refusal = { reason: AgentctlRcRejectReason; action: string; expiresAt: number }
487
488function refuse(reason: AgentctlRcRejectReason, action: string, expiresAt: number): Refusal {
489 return { reason, action, expiresAt }
490}
491
492// How long a refusal of a readable body is remembered: until the request's
493// own expiry when that is valid, after which the file can only be refused as
494// expired, else for the longest lifetime a request may ask for.
495function lifetimeOf(expiresAt: unknown, now: number): number {
496 const isValid = typeof expiresAt === 'number' && Number.isFinite(expiresAt) && expiresAt > now && expiresAt - now <= MAX_LIFETIME_MS
497 return isValid ? expiresAt : now + MAX_LIFETIME_MS
498}
499
500async function loadRejected($: EngineInterface): Promise<AgentctlRcRejected[]> {
501 const held = await $.state.get({ plugin: 'agentctl-remote-control', key: 'rejected' })
502 return [...(held.value ?? [])]
503}
504
505async function saveRejected($: EngineInterface, rejected: AgentctlRcRejected[]): Promise<void> {
506 await $.state.set({ plugin: 'agentctl-remote-control', key: 'rejected' }, rejected)
507}
508
509// Adds a refusal to the bounded list, oldest first. Room is made by evicting
510// the oldest entry whose expiry has passed; when none has, the oldest entry
511// goes, so the newest refusal is still recorded and answered.
512function remember(rejected: AgentctlRcRejected[], entry: AgentctlRcRejected, now: number): void {
513 while (rejected.length >= MAX_REJECTED) {
514 const expired = rejected.findIndex((r) => now >= r.expiresAt)
515 rejected.splice(expired === -1 ? 0 : expired, 1)
516 }
517 rejected.push(entry)
518}
519
520// Writes a refusal's acknowledgement once the directory exists, then forgets
521// its text; until then the same bytes are tried again on every poll.
522async function deliverRejection($: EngineInterface, ctx: Context, rejected: AgentctlRcRejected[], entry: AgentctlRcRejected): Promise<void> {
523 if (entry.ack === undefined) return
524 if (!(await writeTransport($, ctx, `${entry.id}.ack.json`, entry.ack))) return
525 delete entry.ack
526 await saveRejected($, rejected)
527}
528
529// Stores the refusal before answering it, so a reload in between delivers the
530// identical bytes and never examines the file again.
531async function reject($: EngineInterface, ctx: Context, rejected: AgentctlRcRejected[], id: string, refusal: Refusal, now: number): Promise<void> {
532 const entry: AgentctlRcRejected = { id, reason: refusal.reason, expiresAt: refusal.expiresAt, ack: rejectedAck(id, refusal.action, refusal.reason, now) }
533 remember(rejected, entry, now)
534 await saveRejected($, rejected)
535 log($, `request ${id}: rejected (${refusal.reason})`)
536 await deliverRejection($, ctx, rejected, entry)
537}
538
539// The owner uid, mode, type and size checks, which come before anything else
540// is decided about a file whose name and real path passed.
541async function isOwnFile($: EngineInterface, ctx: Context, path: string, size: number): Promise<boolean> {
542 const meta = await hostStat($, path)
543 return meta !== undefined && meta.uid === ctx.uid && meta.mode === '600' && meta.type === 'Regular File' && size <= MAX_REQUEST_BYTES
544}
545
546// Validates the body of one request file whose name, real path and metadata
547// already passed, in the contract's order, and answers the entry to store or
548// the refusal. `known` holds every id the session admitted or refused.
549async function validate($: EngineInterface, known: (id: string) => boolean, id: string, path: string, now: number): Promise<AgentctlRcEntry | Refusal> {
550 const text = await $.fs.read(path).catch(() => undefined)
551 let body: unknown
552 try {
553 body = typeof text === 'string' ? JSON.parse(text) : undefined
554 } catch {
555 body = undefined
556 }
557 // A body under an unknown version cannot be trusted to name its action.
558 if (!isObject(body) || body.v !== 1) return refuse('version', '', now + MAX_LIFETIME_MS)
559 const rawAction = stringOf(body.action) ?? ''
560 if (rawAction !== 'status' && rawAction !== 'reconnect') return refuse('action', rawAction, lifetimeOf(body.expiresAt, now))
561 const action: AgentctlRcAction = rawAction
562 const expiresAt = body.expiresAt
563 // A lifetime beyond the cap would keep its entry, and the table slot,
564 // longer than the table's eviction rule allows for.
565 if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt <= now || expiresAt - now > MAX_LIFETIME_MS) {
566 return refuse('expired', action, now + MAX_LIFETIME_MS)
567 }
568 if (body.id !== id) {
569 const other = stringOf(body.id)
570 if (other !== undefined && known(other)) return refuse('duplicate', action, expiresAt)
571 return refuse('name', action, now + MAX_LIFETIME_MS)
572 }
573 const subject = body.subject
574 if (!isObject(subject) || typeof subject.service !== 'string' || subject.service === '') return refuse('subject', action, expiresAt)
575
576 const ack: AgentctlRcAck = { v: 1, id, action, state: 'accepted', answeredAt: now }
577 return { phase: 'accepted', action, origin: 'file', admittedAt: now, expiresAt, ack: { text: JSON.stringify(ack), published: false } }
578}
579
580// Admits the request files neither in the table nor in the refused list. A
581// file whose name or real path fails is ignored without an answer; one whose
582// metadata fails is refused as such; past that, a full table answers `busy`
583// without reading the body, and admits nothing.
584async function admit($: EngineInterface, ctx: Context, table: Table, rejected: AgentctlRcRejected[], inputs: Inputs, now: number): Promise<void> {
585 const realDir = await transportReal($, ctx)
586 if (realDir === undefined) return
587 const refused = new Set(rejected.map((r) => r.id))
588 const known = (id: string): boolean => id in table || refused.has(id)
589 const ids = new Map<string, string>()
590 for (const entry of await $.fs.list(ctx.transportDir)) {
591 const match = entry.isLink ? null : REQUEST_NAME.exec(entry.name)
592 if (match !== null) ids.set(entry.name, match[1] ?? '')
593 }
594 for (const name of [...ids.keys()].sort()) {
595 const id = ids.get(name) ?? ''
596 if (known(id)) continue
597 try {
598 const path = joinPath(ctx.transportDir, name)
599 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
600 if (stat?.realPath !== joinPath(realDir, name)) continue
601 let outcome: AgentctlRcEntry | Refusal
602 if (!(await isOwnFile($, ctx, path, stat.size))) outcome = refuse('metadata', '', now + MAX_LIFETIME_MS)
603 else if (isFull(table)) outcome = refuse('busy', '', now + MAX_LIFETIME_MS)
604 else outcome = await validate($, known, id, path, now)
605 if ('reason' in outcome) {
606 refused.add(id)
607 await reject($, ctx, rejected, id, outcome, now)
608 continue
609 }
610 table[id] = outcome
611 await save($, table)
612 log($, `request ${id}: accepted ${outcome.action}`)
613 await step($, ctx, table, inputs, id, outcome, now)
614 } catch {
615 log($, `request file ${name}: admission failed`)
616 }
617 }
618}
619
620async function poll($: EngineInterface, ctx: Context): Promise<void> {
621 const now = await $.clock.now()
622 const inputs = await inputsOf($, ctx)
623 const table = await loadTable($)
624 if (evict(table, now)) await save($, table)
625 for (const [id, entry] of Object.entries(table)) {
626 if (entry.phase === 'published') continue
627 try {
628 await step($, ctx, table, inputs, id, entry, now)
629 } catch {
630 log($, `request ${id}: delivery failed; retrying on the next poll`)
631 }
632 }
633 const rejected = await loadRejected($)
634 for (const entry of rejected) {
635 try {
636 await deliverRejection($, ctx, rejected, entry)
637 } catch {
638 log($, `request ${entry.id}: delivery failed; retrying on the next poll`)
639 }
640 }
641 await admit($, ctx, table, rejected, inputs, now)
642}
643
644function tick($: EngineInterface): void {
645 const ctx = context
646 if (ctx === undefined || isPolling) return
647 isPolling = true
648 serial(() => poll($, ctx))
649 .catch((error: unknown) => log($, `poll failed: ${String(error)}`))
650 .finally(() => {
651 isPolling = false
652 })
653}
654
655async function statusText($: EngineInterface): Promise<string> {
656 const lines: string[] = []
657 const version = await $.session.version()
658 const ctx = context
659 if (ctx === undefined) {
660 lines.push(`agentctl-remote-control is inactive: ${refusal ?? 'not started'}.`)
661 } else {
662 const bridge = await observeBridge($, await readRecord($, ctx.recordPath))
663 lines.push(`Remote Control bridge: ${bridge.present ? 'present' : 'absent'}, generation ${bridge.generation}`)
664 }
665 const provenance = await provenanceOf($)
666 const setOrNot = (value: boolean): string => (value ? 'set' : 'unset')
667 // The directory spellings name the account layout of the machine and are
668 // kept out of the transcript; only the response file carries them.
669 lines.push(
670 `Surfaces: ${(await $.session.surfaces()).join(', ') || 'none'}`,
671 `Claude Code: ${version.base ?? version.version}`,
672 `remote-control command: ${(await $.command.list()).some((c) => c.name === 'remote-control') ? 'listed' : 'not listed'}`,
673 'Provenance:',
674 ` HOME ${setOrNot((await $.env.get('HOME')) !== undefined)}`,
675 ` CLAUDE_CONFIG_DIR ${setOrNot(provenance.configDir.set)}`,
676 ` CLAUDE_SECURESTORAGE_CONFIG_DIR ${setOrNot(provenance.secureStorageDir.set)}`,
677 ` CLAUDE_CODE_OAUTH_TOKEN ${setOrNot(provenance.oauthTokenSet)}`,
678 ` ANTHROPIC_API_KEY ${setOrNot(provenance.apiKeySet)}`,
679 ` ANTHROPIC_BASE_URL ${setOrNot(provenance.baseUrlSet)}`,
680 ` first-party authorization ${provenance.authorized ? 'yes' : 'no'}`,
681 )
682 return redact(lines.join('\n'))
683}
684
685// Enqueues a reconnect through the same table a request file goes through;
686// the poll applies the rules, because $.command.run is refused inside a hook
687// the turn is waiting on.
688async function requestReconnect($: EngineInterface): Promise<string> {
689 if (context === undefined) return redact(`agentctl-remote-control is inactive: ${refusal ?? 'not started'}.`)
690 return serial(async () => {
691 const table = await loadTable($)
692 if (Object.values(table).some((e) => e.origin === 'command' && e.phase !== 'published')) return 'A reconnect is already requested.'
693 if (isFull(table)) return 'Too many requests are pending; try again shortly.'
694 const now = await $.clock.now()
695 table[newId()] = { phase: 'accepted', action: 'reconnect', origin: 'command', admittedAt: now, expiresAt: now + COMMAND_LIFETIME_MS }
696 await save($, table)
697 return 'requested'
698 })
699}
700
701// Marks what is still pending as ended when the session ends for good.
702async function endPending($: EngineInterface, ctx: Context): Promise<void> {
703 const now = await $.clock.now()
704 const table = await loadTable($)
705 const inputs = await inputsOf($, ctx)
706 for (const [id, entry] of Object.entries(table)) {
707 if (entry.phase === 'accepted' || entry.phase === 'waiting_idle') {
708 await finish($, ctx, table, inputs, id, entry, 'cancelled', now, 'session_ended').catch(() => undefined)
709 } else if (entry.phase === 'running') {
710 await finish($, ctx, table, inputs, id, entry, 'not_confirmed', now, 'session_ended').catch(() => undefined)
711 }
712 }
713}
714
715export const register: Register = (on) => {
716 on('session.start', async ($, e, next) => {
717 ticker?.cancel()
718 ticker = undefined
719 context = undefined
720 refusal = undefined
721 turns = new Set<string>()
722 isPolling = false
723 bridgeMark = undefined
724 sawAbsent = false
725 queue = Promise.resolve()
726
727 await $.command.register({
728 name: 'agentctl-rc',
729 description: 'Show this session\'s Remote Control bridge status, or "reconnect" it.',
730 argumentHint: '[reconnect]',
731 immediate: true,
732 })
733 const established = await establish($).catch((error: unknown) => `start-up failed: ${String(error)}`)
734 if (typeof established === 'string') {
735 // Stored redacted: a host error can quote a URL or a bridge id, and
736 // the refusal is repeated in command answers.
737 refusal = redact(established)
738 log($, `inactive: ${refusal}`)
739 return next(e)
740 }
741 context = established
742 ticker = $.clock.every(POLL_MS, () => tick($))
743 return next(e)
744 })
745
746 on('session.end', async ($, e, next) => {
747 // A /clear ends the conversation but not the process: its registry
748 // record and transport directory stay, and no session.start follows, so
749 // the poll keeps running for the process's next conversation.
750 if (e.reason !== 'clear') {
751 ticker?.cancel()
752 ticker = undefined
753 const ctx = context
754 context = undefined
755 if (ctx !== undefined) await serial(() => endPending($, ctx)).catch(() => undefined)
756 }
757 return next(e)
758 })
759
760 on('turn.start', async ($, e, next) => {
761 turns.add(e.turnId)
762 return next(e)
763 })
764
765 on('turn.complete', async ($, e, next) => {
766 turns.delete(e.turnId)
767 return next(e)
768 })
769
770 on('command.run', { command: 'agentctl-rc' }, async ($, e) => {
771 const args = e.args.trim()
772 if (args === '') return { text: await statusText($) }
773 if (args === 'reconnect') return { text: await requestReconnect($) }
774 return { text: 'Usage: /agentctl-rc [reconnect]' }
775 }).catch(() => ({ text: '/agentctl-rc failed; the debug log has the reason.' }))
776}
777types/index.d.ts 127 lines1// The session state agentctl-remote-control keeps in `$.state`, and the
2// shapes of the files it exchanges with agentctl. Values in `$.state` are
3// readable by every plugin in the session, so nothing here ever holds a
4// bridge id, a URL or a credential.
5
6/** What a request asks for. */
7export type AgentctlRcAction = 'status' | 'reconnect'
8
9/** Where a request came from: a request file agentctl wrote, or `/agentctl-rc reconnect`. */
10export type AgentctlRcOrigin = 'file' | 'command'
11
12/**
13 * Where a request stands. `accepted` and `waiting_idle` still go through the
14 * rule list; `running` means `remote-control` was issued and is only
15 * observed; `final` holds a stored answer not yet delivered; `published`
16 * is done.
17 */
18export type AgentctlRcPhase = 'accepted' | 'waiting_idle' | 'running' | 'final' | 'published'
19
20/** The final answer of a request. `ok` answers `status`; the rest answer `reconnect`. */
21export type AgentctlRcResult = 'ok' | 'reconnected' | 'already_connected' | 'unavailable' | 'not_confirmed' | 'expired' | 'cancelled'
22
23/** Why a request file was rejected at admission. */
24export type AgentctlRcRejectReason = 'name' | 'expired' | 'duplicate' | 'metadata' | 'action' | 'subject' | 'version' | 'busy'
25
26/**
27 * An environment variable as the session sees it: whether it is set, and its
28 * spelling when it is. `value` is required only when `set` is true; for an
29 * unset variable it may be absent and is ignored.
30 */
31export type AgentctlRcEnvValue = { set: boolean; value?: string }
32
33/**
34 * The environment the session's keychain service name is derived from.
35 * Directory spellings and presence flags only, never a secret.
36 */
37export type AgentctlRcProvenance = {
38 home: string
39 configDir: AgentctlRcEnvValue
40 secureStorageDir: AgentctlRcEnvValue
41 oauthTokenSet: boolean
42 apiKeySet: boolean
43 baseUrlSet: boolean
44 authorized: boolean
45}
46
47/**
48 * The acknowledgement file `<id>.ack.json`, written once per admitted file.
49 * A rejection carries an empty `action` when the body was not read or its
50 * version is unknown.
51 */
52export type AgentctlRcAck = {
53 v: 1
54 id: string
55 action: string
56 state: 'accepted' | 'rejected'
57 reason?: AgentctlRcRejectReason
58 answeredAt: number
59}
60
61/** The response file `<id>.response.json`, written once per accepted request when final. */
62export type AgentctlRcResponse = {
63 v: 1
64 id: string
65 action: AgentctlRcAction
66 result: AgentctlRcResult
67 answeredAt: number
68 bridge: { present: boolean; generation: number }
69 surfaces: string[]
70 version: string
71 /** Whether `remote-control` is in the session's command list when the answer is written. */
72 remoteControlListed: boolean
73 provenance: AgentctlRcProvenance
74 reason?: string
75}
76
77/**
78 * A request file the mod refused. A file whose id is listed is never admitted.
79 * The entry is kept at least until `expiresAt`, and is evicted only to make
80 * room, oldest expired first.
81 */
82export type AgentctlRcRejected = {
83 id: string
84 reason: AgentctlRcRejectReason
85 expiresAt: number
86 /** The acknowledgement text while it is not yet written; absent once it is. */
87 ack?: string
88}
89
90/** A delivery that must happen exactly once: the exact text, and whether it went out. */
91export type AgentctlRcDelivery = { text: string; published: boolean }
92
93/** One request in the table, keyed by its 32-hex id. */
94export type AgentctlRcEntry = {
95 phase: AgentctlRcPhase
96 action: AgentctlRcAction
97 origin: AgentctlRcOrigin
98 admittedAt: number
99 /** When the request expires. No entry leaves the table before this time. */
100 expiresAt: number
101 /** When `remote-control` was issued. */
102 runStartedAt?: number
103 /** When an issued run stops being watched for a new bridge. */
104 observeUntil?: number
105 /** The session id the registry record showed when the run was issued. */
106 runSessionId?: string
107 /** The acknowledgement, for a request that came from a file. */
108 ack?: AgentctlRcDelivery
109 /** The final response; its text never changes once stored. */
110 terminal?: { response: string; published: boolean }
111}
112
113declare module 'claude-code' {
114 interface PluginState {
115 'agentctl-remote-control': {
116 /** Every request this session admitted: at most 1 024 entries, 64 of them not yet answered. */
117 requests: Record<string, AgentctlRcEntry>
118 /** The request files this session refused, oldest first: at most 256 entries. */
119 rejected: AgentctlRcRejected[]
120 /** How many distinct bridges this session has been seen to hold. */
121 generation: number
122 /** The uid `/usr/bin/id -u` answered at session start. */
123 uid: number
124 }
125 }
126}
127