conductor-remote 0.0.0-development

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sidecar.ts ADDED
@@ -0,0 +1,194 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import net from 'node:net'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ /**
8
+ * Client for Conductor's sidecar IPC — the `conductor-runtime sidecar` process
9
+ * that owns every live `claude`/`codex` agent. The desktop app drives it over a
10
+ * unix socket speaking newline-delimited JSON-RPC 2.0; we speak the same
11
+ * protocol as an additional local client.
12
+ *
13
+ * This is by far the most precise write path: prompts are addressed by
14
+ * `sessionId`, so there is no window focus or AppleScript involved and the app's
15
+ * UI updates correctly because it's the real dispatch path. It is also the most
16
+ * update-fragile surface (a private, versioned IPC), which is why it lives
17
+ * behind the Actuator interface and falls back to AppleScript when the socket
18
+ * can't be reached (see writes.ts).
19
+ *
20
+ * Reverse-engineered from conductor-runtime (Conductor 0.76):
21
+ * - socket: `$TMPDIR/conductor-sidecar-v2-<sidecarPid>.sock`
22
+ * - transport: newline-delimited JSON-RPC 2.0 (`{jsonrpc,id,method,params}`)
23
+ * - local auth: the literal `{ userId: 'local', auth: 'local' }`
24
+ * - send prompt: method `query`, params `{ type: 'sendUserMessageRequest', … }`
25
+ * - safe read: method `contextUsage`, params `{ sessionId, …auth }`
26
+ *
27
+ * Stale socket files from exited sidecars linger in `$TMPDIR`, so discovery is
28
+ * connectivity-based: we try candidates newest-first and use the first that
29
+ * actually accepts a connection.
30
+ */
31
+
32
+ const SOCKET_PREFIX = 'conductor-sidecar-v2-'
33
+ const LOCAL_AUTH = { userId: 'local', auth: 'local' } as const
34
+
35
+ /** Candidate sidecar socket paths in `$TMPDIR`, newest mtime first. */
36
+ function listSidecarSockets(): string[] {
37
+ const dir = os.tmpdir()
38
+ let names: string[]
39
+ try {
40
+ names = fs.readdirSync(dir)
41
+ } catch {
42
+ return []
43
+ }
44
+ const found: { p: string; m: number }[] = []
45
+ for (const name of names) {
46
+ if (!(name.startsWith(SOCKET_PREFIX) && name.endsWith('.sock'))) continue
47
+ const p = path.join(dir, name)
48
+ try {
49
+ const st = fs.statSync(p)
50
+ if (st.isSocket()) found.push({ p, m: st.mtimeMs })
51
+ } catch {
52
+ // vanished between readdir and stat — skip
53
+ }
54
+ }
55
+ return found.sort((a, b) => b.m - a.m).map(x => x.p)
56
+ }
57
+
58
+ function isConnRefused(err: unknown): boolean {
59
+ const code = (err as { code?: string })?.code
60
+ return code === 'ECONNREFUSED' || code === 'ENOENT'
61
+ }
62
+
63
+ interface RpcMessage {
64
+ id?: unknown
65
+ result?: unknown
66
+ error?: { code: number; message: string }
67
+ }
68
+
69
+ /** One JSON-RPC request/response over a fresh connection to a specific socket. */
70
+ function rpcOnSocket(
71
+ socketPath: string,
72
+ method: string,
73
+ params: Record<string, unknown>,
74
+ timeoutMs: number
75
+ ): Promise<unknown> {
76
+ return new Promise((resolve, reject) => {
77
+ const sock = net.connect(socketPath)
78
+ const id = 1
79
+ let buf = ''
80
+ let settled = false
81
+ const finish = (err: Error | null, val?: unknown): void => {
82
+ if (settled) return
83
+ settled = true
84
+ clearTimeout(timer)
85
+ sock.destroy()
86
+ if (err) reject(err)
87
+ else resolve(val)
88
+ }
89
+ const timer = setTimeout(() => finish(new Error(`sidecar RPC "${method}" timed out`)), timeoutMs)
90
+
91
+ sock.on('connect', () => {
92
+ sock.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
93
+ })
94
+ sock.on('data', chunk => {
95
+ buf += chunk.toString('utf8')
96
+ // The sidecar also pushes unsolicited notifications — ignore anything
97
+ // that isn't the response to our id.
98
+ let nl = buf.indexOf('\n')
99
+ while (nl >= 0) {
100
+ const line = buf.slice(0, nl).trim()
101
+ buf = buf.slice(nl + 1)
102
+ nl = buf.indexOf('\n')
103
+ if (!line) continue
104
+ let msg: RpcMessage
105
+ try {
106
+ msg = JSON.parse(line) as RpcMessage
107
+ } catch {
108
+ continue
109
+ }
110
+ if (msg.id === id && ('result' in msg || 'error' in msg)) {
111
+ if (msg.error) return finish(new Error(msg.error.message || `sidecar RPC error ${msg.error.code}`))
112
+ return finish(null, msg.result)
113
+ }
114
+ }
115
+ })
116
+ sock.on('error', e => finish(e instanceof Error ? e : new Error(String(e))))
117
+ sock.on('close', () => finish(new Error('sidecar closed the connection before responding')))
118
+ })
119
+ }
120
+
121
+ /** Try each candidate socket, skipping stale ones (connection refused). */
122
+ async function rpc(method: string, params: Record<string, unknown>, timeoutMs = 8000): Promise<unknown> {
123
+ const candidates = listSidecarSockets()
124
+ if (!candidates.length) throw new Error('no Conductor sidecar socket found — is Conductor running?')
125
+ let lastErr: unknown
126
+ for (const socketPath of candidates) {
127
+ try {
128
+ return await rpcOnSocket(socketPath, method, params, timeoutMs)
129
+ } catch (err) {
130
+ lastErr = err
131
+ if (isConnRefused(err)) continue // stale socket file, try the next
132
+ throw err // a real RPC/protocol error — surface it, don't mask
133
+ }
134
+ }
135
+ throw lastErr instanceof Error ? lastErr : new Error('sidecar unreachable')
136
+ }
137
+
138
+ /** Resolve a connectable sidecar socket, or null. Used to decide write strategy. */
139
+ export function sidecarSocket(timeoutMs = 800): Promise<string | null> {
140
+ const candidates = listSidecarSockets()
141
+ return (async () => {
142
+ for (const p of candidates) {
143
+ const ok = await canConnect(p, timeoutMs)
144
+ if (ok) return p
145
+ }
146
+ return null
147
+ })()
148
+ }
149
+
150
+ function canConnect(socketPath: string, timeoutMs: number): Promise<boolean> {
151
+ return new Promise(resolve => {
152
+ const sock = net.connect(socketPath)
153
+ let done = false
154
+ const finish = (ok: boolean): void => {
155
+ if (done) return
156
+ done = true
157
+ clearTimeout(t)
158
+ sock.destroy()
159
+ resolve(ok)
160
+ }
161
+ const t = setTimeout(() => finish(false), timeoutMs)
162
+ sock.on('connect', () => finish(true))
163
+ sock.on('error', () => finish(false))
164
+ })
165
+ }
166
+
167
+ /** True when a precise (sidecar) send path is currently reachable. */
168
+ export async function sidecarAvailable(): Promise<boolean> {
169
+ return (await sidecarSocket()) !== null
170
+ }
171
+
172
+ /**
173
+ * Deliver a prompt to a specific session — the real send path, precisely
174
+ * targeted. Resolves once the sidecar has accepted (queued/sent) the message.
175
+ */
176
+ export async function sidecarSendUserMessage(sessionId: string, text: string): Promise<void> {
177
+ await rpc('query', {
178
+ type: 'sendUserMessageRequest',
179
+ ...LOCAL_AUTH,
180
+ sessionId,
181
+ id: randomUUID(),
182
+ message: text,
183
+ agentMessage: text,
184
+ deliveryMode: 'default'
185
+ })
186
+ }
187
+
188
+ /**
189
+ * Read a session's context usage. Pure read — no turn is triggered — so it's the
190
+ * safe way to prove the socket + auth + framing work end to end.
191
+ */
192
+ export function sidecarContextUsage(sessionId: string): Promise<unknown> {
193
+ return rpc('contextUsage', { ...LOCAL_AUTH, sessionId }, 5000)
194
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Conductor stores each turn's raw Claude Code SDK stream JSON in
3
+ * `session_messages.content`. User-typed prompts are stored as plain text.
4
+ * This turns a row into a compact, phone-renderable entry.
5
+ */
6
+
7
+ export interface TranscriptEntry {
8
+ id: string
9
+ rowid: number
10
+ /** Display role: user | assistant | tool | thinking | system */
11
+ role: 'user' | 'assistant' | 'tool' | 'thinking' | 'system'
12
+ /** Human-readable text (may be empty for pure tool rows). */
13
+ text: string
14
+ /** Tool name when role === 'tool'. */
15
+ tool?: string
16
+ ts: string
17
+ /** True when the message is queued but not yet sent (queue_order set, sent_at null). */
18
+ queued: boolean
19
+ }
20
+
21
+ interface RawRow {
22
+ rowid: number
23
+ id: string
24
+ role: string | null
25
+ content: string | null
26
+ full_message: string | null
27
+ created_at: string
28
+ sent_at: string | null
29
+ queue_order: number | null
30
+ }
31
+
32
+ interface SdkBlock {
33
+ type: string
34
+ text?: string
35
+ name?: string
36
+ input?: unknown
37
+ content?: unknown
38
+ }
39
+
40
+ function textFromBlocks(blocks: SdkBlock[]): { text: string; tool?: string } {
41
+ const parts: string[] = []
42
+ let tool: string | undefined
43
+ for (const b of blocks) {
44
+ if (b.type === 'text' && b.text) parts.push(b.text)
45
+ else if (b.type === 'thinking' && typeof b.text === 'string') parts.push(b.text)
46
+ else if (b.type === 'tool_use') {
47
+ tool = b.name
48
+ const inputStr = summarizeToolInput(b.input)
49
+ parts.push(inputStr ? `▸ ${b.name}: ${inputStr}` : `▸ ${b.name}`)
50
+ } else if (b.type === 'tool_result') {
51
+ parts.push(summarizeToolResult(b.content))
52
+ }
53
+ }
54
+ return { text: parts.join('\n').trim(), tool }
55
+ }
56
+
57
+ function summarizeToolInput(input: unknown): string {
58
+ if (!input || typeof input !== 'object') return ''
59
+ const o = input as Record<string, unknown>
60
+ const key = o.command ?? o.file_path ?? o.path ?? o.pattern ?? o.description ?? o.prompt
61
+ const s = typeof key === 'string' ? key : JSON.stringify(o)
62
+ return s.length > 140 ? `${s.slice(0, 140)}…` : s
63
+ }
64
+
65
+ function summarizeToolResult(content: unknown): string {
66
+ let s = ''
67
+ if (typeof content === 'string') s = content
68
+ else if (Array.isArray(content)) {
69
+ s = content
70
+ .map(c => (c && typeof c === 'object' && 'text' in c ? String((c as { text: unknown }).text) : ''))
71
+ .join('')
72
+ }
73
+ s = s.trim()
74
+ if (!s) return '↳ (result)'
75
+ return `↳ ${s.length > 200 ? `${s.slice(0, 200)}…` : s}`
76
+ }
77
+
78
+ export function parseMessage(row: RawRow): TranscriptEntry | null {
79
+ const queued = row.queue_order !== null && row.sent_at === null
80
+ const base = { id: row.id, rowid: row.rowid, ts: row.created_at, queued }
81
+ const content = row.content ?? ''
82
+
83
+ // Plain user prompt (not SDK JSON).
84
+ if (!content.startsWith('{')) {
85
+ if (!content.trim()) return null
86
+ return { ...base, role: 'user', text: content }
87
+ }
88
+
89
+ let parsed: { type?: string; subtype?: string; message?: { content?: SdkBlock[] } }
90
+ try {
91
+ parsed = JSON.parse(content)
92
+ } catch {
93
+ return { ...base, role: 'system', text: content.slice(0, 200) }
94
+ }
95
+
96
+ // Skip pure bookkeeping frames (token accounting, etc.).
97
+ if (parsed.type === 'system' && parsed.subtype === 'thinking_tokens') return null
98
+ if (parsed.type === 'result') return null
99
+
100
+ const blocks = parsed.message?.content
101
+ if (Array.isArray(blocks)) {
102
+ const { text, tool } = textFromBlocks(blocks)
103
+ if (!text) return null
104
+ if (tool) return { ...base, role: 'tool', text, tool }
105
+ if (parsed.type === 'user') return { ...base, role: 'user', text }
106
+ return { ...base, role: 'assistant', text }
107
+ }
108
+
109
+ if (parsed.type === 'system') return null
110
+ return { ...base, role: 'system', text: content.slice(0, 200) }
111
+ }
package/src/writes.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import type { Workspace } from './reads.ts'
4
+ import { sidecarAvailable, sidecarSendUserMessage } from './sidecar.ts'
5
+
6
+ const exec = promisify(execFile)
7
+
8
+ export interface SendResult {
9
+ ok: boolean
10
+ strategy: string
11
+ warning?: string
12
+ error?: string
13
+ }
14
+
15
+ /** Who to deliver a prompt to. `sessionId` is the precise target; `workspace` carries the worktree + focus context. */
16
+ export interface SendTarget {
17
+ workspace: Workspace
18
+ sessionId: string | null
19
+ }
20
+
21
+ export interface Actuator {
22
+ readonly name: string
23
+ /** Human-readable note about this strategy's limits, surfaced in the UI. */
24
+ readonly caveat: string
25
+ /** True when delivery is addressed to a specific session (no window-focus dependency). */
26
+ readonly precise: boolean
27
+ send: (target: SendTarget, text: string) => Promise<SendResult>
28
+ /** Runtime availability check (e.g. the sidecar socket must be reachable). */
29
+ available?: () => Promise<boolean>
30
+ }
31
+
32
+ /**
33
+ * The sidecar IPC path — the precise, per-session write. Delivers straight to
34
+ * `sessionId` over Conductor's own dispatch socket (see sidecar.ts), so it needs
35
+ * no window focus and the app UI reflects the turn correctly.
36
+ *
37
+ * Opt-in (WRITE_STRATEGY=sidecar) because it speaks a private, versioned IPC and
38
+ * hasn't been validated by an automated live send (that would inject a prompt
39
+ * into a running agent). It is the intended default once you've confirmed it on
40
+ * your setup.
41
+ */
42
+ export class SidecarActuator implements Actuator {
43
+ readonly name = 'sidecar'
44
+ readonly caveat =
45
+ 'Delivered straight to the target session over Conductor’s dispatch socket — precise per-workspace targeting.'
46
+ readonly precise = true
47
+
48
+ available(): Promise<boolean> {
49
+ return sidecarAvailable()
50
+ }
51
+
52
+ async send(target: SendTarget, text: string): Promise<SendResult> {
53
+ const sessionId = target.sessionId ?? target.workspace.active_session_id
54
+ if (!sessionId) return { ok: false, strategy: this.name, error: 'no session id to target' }
55
+ try {
56
+ await sidecarSendUserMessage(sessionId, text)
57
+ return { ok: true, strategy: this.name }
58
+ } catch (err) {
59
+ return { ok: false, strategy: this.name, error: err instanceof Error ? err.message : String(err) }
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Drives Conductor's real send path via macOS Accessibility (AppleScript):
66
+ * activate the app, paste the prompt, press Enter. Uses whatever model /
67
+ * permission mode the session already has (zero risk of altering the agent),
68
+ * which is why it's the default. Its one limit is that it lands in the session
69
+ * Conductor currently has focused — bring the target workspace to front first.
70
+ */
71
+ export class AppleScriptActuator implements Actuator {
72
+ readonly name = 'applescript'
73
+ readonly caveat =
74
+ 'Lands in the session Conductor currently has focused. Bring the target workspace to front first; for precise targeting run with WRITE_STRATEGY=sidecar.'
75
+ readonly precise = false
76
+
77
+ async send(_target: SendTarget, text: string): Promise<SendResult> {
78
+ // Paste beats keystroke for long/multibyte prompts. We stash the clipboard,
79
+ // paste, send, and restore.
80
+ const script = `
81
+ set savedClipboard to the clipboard
82
+ set the clipboard to (do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
83
+ tell application "Conductor" to activate
84
+ delay 0.35
85
+ tell application "System Events"
86
+ keystroke "v" using {command down}
87
+ delay 0.1
88
+ key code 36
89
+ end tell
90
+ delay 0.1
91
+ set the clipboard to savedClipboard
92
+ `.trim()
93
+ // Pass the prompt via a temp file + env to avoid AppleScript string escaping.
94
+ const os = await import('node:os')
95
+ const fs = await import('node:fs/promises')
96
+ const path = await import('node:path')
97
+ const tmp = path.join(os.tmpdir(), `relay-prompt-${process.pid}-${Date.now()}.txt`)
98
+ await fs.writeFile(tmp, text, 'utf8')
99
+ try {
100
+ await exec('osascript', ['-e', script], {
101
+ env: { ...process.env, RELAY_PROMPT_FILE: tmp },
102
+ timeout: 10000
103
+ })
104
+ return {
105
+ ok: true,
106
+ strategy: this.name,
107
+ warning: 'Delivered to the focused Conductor session — confirm it landed in the intended workspace.'
108
+ }
109
+ } catch (err) {
110
+ return {
111
+ ok: false,
112
+ strategy: this.name,
113
+ error: err instanceof Error ? err.message : String(err)
114
+ }
115
+ } finally {
116
+ await fs.rm(tmp, { force: true }).catch(() => undefined)
117
+ }
118
+ }
119
+ }
120
+
121
+ export type WriteStrategy = 'applescript' | 'sidecar'
122
+
123
+ export function pickActuator(strategy: WriteStrategy): Actuator {
124
+ return strategy === 'sidecar' ? new SidecarActuator() : new AppleScriptActuator()
125
+ }
126
+
127
+ /** Effective actuator description for the UI, factoring in runtime availability. */
128
+ export async function describeActuator(
129
+ actuator: Actuator
130
+ ): Promise<{ name: string; caveat: string; precise: boolean; available: boolean }> {
131
+ const available = actuator.available ? await actuator.available().catch(() => false) : true
132
+ return { name: actuator.name, caveat: actuator.caveat, precise: actuator.precise, available }
133
+ }