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/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/cli.js +64 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/index-DiiwvzTm.css +1 -0
- package/dist/assets/index-DtY88CkY.js +40 -0
- package/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/icon-maskable-512.png +0 -0
- package/dist/icon.svg +28 -0
- package/dist/index.html +20 -0
- package/dist/manifest.webmanifest +1 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-9c191d2f.js +1 -0
- package/package.json +86 -0
- package/scripts/dev.ts +75 -0
- package/scripts/devtools-recon.js +42 -0
- package/scripts/gen-icons.ts +22 -0
- package/scripts/service.ts +308 -0
- package/src/config.ts +81 -0
- package/src/db.ts +38 -0
- package/src/git.ts +116 -0
- package/src/reads.ts +164 -0
- package/src/server.ts +149 -0
- package/src/sidecar.ts +194 -0
- package/src/transcript.ts +111 -0
- package/src/writes.ts +133 -0
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
|
+
}
|