conductor-remote 1.9.0 → 1.9.1
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/bin/cli.js +21 -6
- package/dist-node/scripts/qr.js +400 -0
- package/dist-node/scripts/service.js +467 -0
- package/dist-node/src/autoupdate.js +160 -0
- package/dist-node/src/config.js +60 -0
- package/dist-node/src/db.js +36 -0
- package/dist-node/src/git.js +98 -0
- package/dist-node/src/icons.js +56 -0
- package/dist-node/src/pkg-root.js +20 -0
- package/dist-node/src/reads.js +108 -0
- package/dist-node/src/server.js +188 -0
- package/dist-node/src/sidecar.js +186 -0
- package/dist-node/src/transcript.js +83 -0
- package/dist-node/src/writes.js +144 -0
- package/package.json +5 -5
- package/scripts/dev.ts +0 -75
- package/scripts/devtools-recon.js +0 -42
- package/scripts/gen-icons.ts +0 -22
- package/scripts/qr.ts +0 -393
- package/scripts/service.ts +0 -485
- package/src/autoupdate.ts +0 -179
- package/src/config.ts +0 -81
- package/src/db.ts +0 -38
- package/src/git.ts +0 -116
- package/src/icons.ts +0 -66
- package/src/reads.ts +0 -178
- package/src/server.ts +0 -194
- package/src/sidecar.ts +0 -194
- package/src/transcript.ts +0 -111
- package/src/writes.ts +0 -179
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
* Client for Conductor's sidecar IPC — the `conductor-runtime sidecar` process
|
|
8
|
+
* that owns every live `claude`/`codex` agent. The desktop app drives it over a
|
|
9
|
+
* unix socket speaking newline-delimited JSON-RPC 2.0; we speak the same
|
|
10
|
+
* protocol as an additional local client.
|
|
11
|
+
*
|
|
12
|
+
* This is by far the most precise write path: prompts are addressed by
|
|
13
|
+
* `sessionId`, so there is no window focus or AppleScript involved and the app's
|
|
14
|
+
* UI updates correctly because it's the real dispatch path. It is also the most
|
|
15
|
+
* update-fragile surface (a private, versioned IPC), which is why it lives
|
|
16
|
+
* behind the Actuator interface and falls back to AppleScript when the socket
|
|
17
|
+
* can't be reached (see writes.ts).
|
|
18
|
+
*
|
|
19
|
+
* Reverse-engineered from conductor-runtime (Conductor 0.76):
|
|
20
|
+
* - socket: `$TMPDIR/conductor-sidecar-v2-<sidecarPid>.sock`
|
|
21
|
+
* - transport: newline-delimited JSON-RPC 2.0 (`{jsonrpc,id,method,params}`)
|
|
22
|
+
* - local auth: the literal `{ userId: 'local', auth: 'local' }`
|
|
23
|
+
* - send prompt: method `query`, params `{ type: 'sendUserMessageRequest', … }`
|
|
24
|
+
* - safe read: method `contextUsage`, params `{ sessionId, …auth }`
|
|
25
|
+
*
|
|
26
|
+
* Stale socket files from exited sidecars linger in `$TMPDIR`, so discovery is
|
|
27
|
+
* connectivity-based: we try candidates newest-first and use the first that
|
|
28
|
+
* actually accepts a connection.
|
|
29
|
+
*/
|
|
30
|
+
const SOCKET_PREFIX = 'conductor-sidecar-v2-';
|
|
31
|
+
const LOCAL_AUTH = { userId: 'local', auth: 'local' };
|
|
32
|
+
/** Candidate sidecar socket paths in `$TMPDIR`, newest mtime first. */
|
|
33
|
+
function listSidecarSockets() {
|
|
34
|
+
const dir = os.tmpdir();
|
|
35
|
+
let names;
|
|
36
|
+
try {
|
|
37
|
+
names = fs.readdirSync(dir);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
if (!(name.startsWith(SOCKET_PREFIX) && name.endsWith('.sock')))
|
|
45
|
+
continue;
|
|
46
|
+
const p = path.join(dir, name);
|
|
47
|
+
try {
|
|
48
|
+
const st = fs.statSync(p);
|
|
49
|
+
if (st.isSocket())
|
|
50
|
+
found.push({ p, m: st.mtimeMs });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// vanished between readdir and stat — skip
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return found.sort((a, b) => b.m - a.m).map(x => x.p);
|
|
57
|
+
}
|
|
58
|
+
function isConnRefused(err) {
|
|
59
|
+
const code = err?.code;
|
|
60
|
+
return code === 'ECONNREFUSED' || code === 'ENOENT';
|
|
61
|
+
}
|
|
62
|
+
/** One JSON-RPC request/response over a fresh connection to a specific socket. */
|
|
63
|
+
function rpcOnSocket(socketPath, method, params, timeoutMs) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const sock = net.connect(socketPath);
|
|
66
|
+
const id = 1;
|
|
67
|
+
let buf = '';
|
|
68
|
+
let settled = false;
|
|
69
|
+
const finish = (err, val) => {
|
|
70
|
+
if (settled)
|
|
71
|
+
return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
sock.destroy();
|
|
75
|
+
if (err)
|
|
76
|
+
reject(err);
|
|
77
|
+
else
|
|
78
|
+
resolve(val);
|
|
79
|
+
};
|
|
80
|
+
const timer = setTimeout(() => finish(new Error(`sidecar RPC "${method}" timed out`)), timeoutMs);
|
|
81
|
+
sock.on('connect', () => {
|
|
82
|
+
sock.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
83
|
+
});
|
|
84
|
+
sock.on('data', chunk => {
|
|
85
|
+
buf += chunk.toString('utf8');
|
|
86
|
+
// The sidecar also pushes unsolicited notifications — ignore anything
|
|
87
|
+
// that isn't the response to our id.
|
|
88
|
+
let nl = buf.indexOf('\n');
|
|
89
|
+
while (nl >= 0) {
|
|
90
|
+
const line = buf.slice(0, nl).trim();
|
|
91
|
+
buf = buf.slice(nl + 1);
|
|
92
|
+
nl = buf.indexOf('\n');
|
|
93
|
+
if (!line)
|
|
94
|
+
continue;
|
|
95
|
+
let msg;
|
|
96
|
+
try {
|
|
97
|
+
msg = JSON.parse(line);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (msg.id === id && ('result' in msg || 'error' in msg)) {
|
|
103
|
+
if (msg.error)
|
|
104
|
+
return finish(new Error(msg.error.message || `sidecar RPC error ${msg.error.code}`));
|
|
105
|
+
return finish(null, msg.result);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
sock.on('error', e => finish(e instanceof Error ? e : new Error(String(e))));
|
|
110
|
+
sock.on('close', () => finish(new Error('sidecar closed the connection before responding')));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Try each candidate socket, skipping stale ones (connection refused). */
|
|
114
|
+
async function rpc(method, params, timeoutMs = 8000) {
|
|
115
|
+
const candidates = listSidecarSockets();
|
|
116
|
+
if (!candidates.length)
|
|
117
|
+
throw new Error('no Conductor sidecar socket found — is Conductor running?');
|
|
118
|
+
let lastErr;
|
|
119
|
+
for (const socketPath of candidates) {
|
|
120
|
+
try {
|
|
121
|
+
return await rpcOnSocket(socketPath, method, params, timeoutMs);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
lastErr = err;
|
|
125
|
+
if (isConnRefused(err))
|
|
126
|
+
continue; // stale socket file, try the next
|
|
127
|
+
throw err; // a real RPC/protocol error — surface it, don't mask
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw lastErr instanceof Error ? lastErr : new Error('sidecar unreachable');
|
|
131
|
+
}
|
|
132
|
+
/** Resolve a connectable sidecar socket, or null. Used to decide write strategy. */
|
|
133
|
+
export function sidecarSocket(timeoutMs = 800) {
|
|
134
|
+
const candidates = listSidecarSockets();
|
|
135
|
+
return (async () => {
|
|
136
|
+
for (const p of candidates) {
|
|
137
|
+
const ok = await canConnect(p, timeoutMs);
|
|
138
|
+
if (ok)
|
|
139
|
+
return p;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
})();
|
|
143
|
+
}
|
|
144
|
+
function canConnect(socketPath, timeoutMs) {
|
|
145
|
+
return new Promise(resolve => {
|
|
146
|
+
const sock = net.connect(socketPath);
|
|
147
|
+
let done = false;
|
|
148
|
+
const finish = (ok) => {
|
|
149
|
+
if (done)
|
|
150
|
+
return;
|
|
151
|
+
done = true;
|
|
152
|
+
clearTimeout(t);
|
|
153
|
+
sock.destroy();
|
|
154
|
+
resolve(ok);
|
|
155
|
+
};
|
|
156
|
+
const t = setTimeout(() => finish(false), timeoutMs);
|
|
157
|
+
sock.on('connect', () => finish(true));
|
|
158
|
+
sock.on('error', () => finish(false));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/** True when a precise (sidecar) send path is currently reachable. */
|
|
162
|
+
export async function sidecarAvailable() {
|
|
163
|
+
return (await sidecarSocket()) !== null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Deliver a prompt to a specific session — the real send path, precisely
|
|
167
|
+
* targeted. Resolves once the sidecar has accepted (queued/sent) the message.
|
|
168
|
+
*/
|
|
169
|
+
export async function sidecarSendUserMessage(sessionId, text) {
|
|
170
|
+
await rpc('query', {
|
|
171
|
+
type: 'sendUserMessageRequest',
|
|
172
|
+
...LOCAL_AUTH,
|
|
173
|
+
sessionId,
|
|
174
|
+
id: randomUUID(),
|
|
175
|
+
message: text,
|
|
176
|
+
agentMessage: text,
|
|
177
|
+
deliveryMode: 'default'
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Read a session's context usage. Pure read — no turn is triggered — so it's the
|
|
182
|
+
* safe way to prove the socket + auth + framing work end to end.
|
|
183
|
+
*/
|
|
184
|
+
export function sidecarContextUsage(sessionId) {
|
|
185
|
+
return rpc('contextUsage', { ...LOCAL_AUTH, sessionId }, 5000);
|
|
186
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
function textFromBlocks(blocks) {
|
|
7
|
+
const parts = [];
|
|
8
|
+
let tool;
|
|
9
|
+
for (const b of blocks) {
|
|
10
|
+
if (b.type === 'text' && b.text)
|
|
11
|
+
parts.push(b.text);
|
|
12
|
+
else if (b.type === 'thinking' && typeof b.text === 'string')
|
|
13
|
+
parts.push(b.text);
|
|
14
|
+
else if (b.type === 'tool_use') {
|
|
15
|
+
tool = b.name;
|
|
16
|
+
const inputStr = summarizeToolInput(b.input);
|
|
17
|
+
parts.push(inputStr ? `▸ ${b.name}: ${inputStr}` : `▸ ${b.name}`);
|
|
18
|
+
}
|
|
19
|
+
else if (b.type === 'tool_result') {
|
|
20
|
+
parts.push(summarizeToolResult(b.content));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { text: parts.join('\n').trim(), tool };
|
|
24
|
+
}
|
|
25
|
+
function summarizeToolInput(input) {
|
|
26
|
+
if (!input || typeof input !== 'object')
|
|
27
|
+
return '';
|
|
28
|
+
const o = input;
|
|
29
|
+
const key = o.command ?? o.file_path ?? o.path ?? o.pattern ?? o.description ?? o.prompt;
|
|
30
|
+
const s = typeof key === 'string' ? key : JSON.stringify(o);
|
|
31
|
+
return s.length > 140 ? `${s.slice(0, 140)}…` : s;
|
|
32
|
+
}
|
|
33
|
+
function summarizeToolResult(content) {
|
|
34
|
+
let s = '';
|
|
35
|
+
if (typeof content === 'string')
|
|
36
|
+
s = content;
|
|
37
|
+
else if (Array.isArray(content)) {
|
|
38
|
+
s = content
|
|
39
|
+
.map(c => (c && typeof c === 'object' && 'text' in c ? String(c.text) : ''))
|
|
40
|
+
.join('');
|
|
41
|
+
}
|
|
42
|
+
s = s.trim();
|
|
43
|
+
if (!s)
|
|
44
|
+
return '↳ (result)';
|
|
45
|
+
return `↳ ${s.length > 200 ? `${s.slice(0, 200)}…` : s}`;
|
|
46
|
+
}
|
|
47
|
+
export function parseMessage(row) {
|
|
48
|
+
const queued = row.queue_order !== null && row.sent_at === null;
|
|
49
|
+
const base = { id: row.id, rowid: row.rowid, ts: row.created_at, queued };
|
|
50
|
+
const content = row.content ?? '';
|
|
51
|
+
// Plain user prompt (not SDK JSON).
|
|
52
|
+
if (!content.startsWith('{')) {
|
|
53
|
+
if (!content.trim())
|
|
54
|
+
return null;
|
|
55
|
+
return { ...base, role: 'user', text: content };
|
|
56
|
+
}
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(content);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { ...base, role: 'system', text: content.slice(0, 200) };
|
|
63
|
+
}
|
|
64
|
+
// Skip pure bookkeeping frames (token accounting, etc.).
|
|
65
|
+
if (parsed.type === 'system' && parsed.subtype === 'thinking_tokens')
|
|
66
|
+
return null;
|
|
67
|
+
if (parsed.type === 'result')
|
|
68
|
+
return null;
|
|
69
|
+
const blocks = parsed.message?.content;
|
|
70
|
+
if (Array.isArray(blocks)) {
|
|
71
|
+
const { text, tool } = textFromBlocks(blocks);
|
|
72
|
+
if (!text)
|
|
73
|
+
return null;
|
|
74
|
+
if (tool)
|
|
75
|
+
return { ...base, role: 'tool', text, tool };
|
|
76
|
+
if (parsed.type === 'user')
|
|
77
|
+
return { ...base, role: 'user', text };
|
|
78
|
+
return { ...base, role: 'assistant', text };
|
|
79
|
+
}
|
|
80
|
+
if (parsed.type === 'system')
|
|
81
|
+
return null;
|
|
82
|
+
return { ...base, role: 'system', text: content.slice(0, 200) };
|
|
83
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
|
|
4
|
+
const exec = promisify(execFile);
|
|
5
|
+
/**
|
|
6
|
+
* The sidecar IPC path — the precise, per-session write. Delivers straight to
|
|
7
|
+
* `sessionId` over Conductor's own dispatch socket (see sidecar.ts), so it needs
|
|
8
|
+
* no window focus and the app UI reflects the turn correctly.
|
|
9
|
+
*
|
|
10
|
+
* Opt-in (WRITE_STRATEGY=sidecar) because it speaks a private, versioned IPC and
|
|
11
|
+
* hasn't been validated by an automated live send (that would inject a prompt
|
|
12
|
+
* into a running agent). It is the intended default once you've confirmed it on
|
|
13
|
+
* your setup.
|
|
14
|
+
*/
|
|
15
|
+
export class SidecarActuator {
|
|
16
|
+
name = 'sidecar';
|
|
17
|
+
caveat = 'Delivered straight to the target session over Conductor’s dispatch socket — precise per-workspace targeting.';
|
|
18
|
+
precise = true;
|
|
19
|
+
available() {
|
|
20
|
+
return sidecarAvailable();
|
|
21
|
+
}
|
|
22
|
+
async send(target, text) {
|
|
23
|
+
const sessionId = target.sessionId ?? target.workspace.active_session_id;
|
|
24
|
+
if (!sessionId)
|
|
25
|
+
return { ok: false, strategy: this.name, error: 'no session id to target' };
|
|
26
|
+
try {
|
|
27
|
+
await sidecarSendUserMessage(sessionId, text);
|
|
28
|
+
return { ok: true, strategy: this.name };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return { ok: false, strategy: this.name, error: err instanceof Error ? err.message : String(err) };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// AppleScript steps that focus a workspace via Conductor's command palette
|
|
36
|
+
// (Esc → Cmd+K → branch → Enter). The branch is read from RELAY_WS_QUERY at run
|
|
37
|
+
// time to dodge AppleScript escaping; the timing delays are load-bearing.
|
|
38
|
+
const FOCUS_WORKSPACE_STEPS = `
|
|
39
|
+
key code 53
|
|
40
|
+
delay 0.25
|
|
41
|
+
keystroke "k" using {command down}
|
|
42
|
+
delay 0.7
|
|
43
|
+
keystroke (system attribute "RELAY_WS_QUERY")
|
|
44
|
+
delay 0.9
|
|
45
|
+
key code 36
|
|
46
|
+
delay 1.3`;
|
|
47
|
+
/** Conductor's command palette matches workspaces by branch — its unique key. A
|
|
48
|
+
* looser query (directory name) can match a command like unarchive, so prefer
|
|
49
|
+
* branch and only fall back when it's absent. */
|
|
50
|
+
function focusQuery(ws) {
|
|
51
|
+
return ws.branch || ws.workspace_name || ws.directory_name || '';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Drives Conductor's real send path via macOS Accessibility (AppleScript): focus
|
|
55
|
+
* the target workspace, paste the prompt, press Enter. Uses whatever model /
|
|
56
|
+
* permission mode the session already has (zero risk of altering the agent),
|
|
57
|
+
* which is why it's the default.
|
|
58
|
+
*
|
|
59
|
+
* Precise targeting comes from focusing the intended workspace first through
|
|
60
|
+
* Conductor's command palette (Cmd+K → branch → Enter) before pasting, so the
|
|
61
|
+
* prompt lands in the right session regardless of what was focused — no private
|
|
62
|
+
* IPC and nothing to rebreak on a Conductor update (unlike the sidecar).
|
|
63
|
+
*/
|
|
64
|
+
export class AppleScriptActuator {
|
|
65
|
+
name = 'applescript';
|
|
66
|
+
caveat = 'Focuses the target workspace via the command palette (Cmd+K) before sending.';
|
|
67
|
+
precise = true;
|
|
68
|
+
async send(target, text) {
|
|
69
|
+
const navQuery = focusQuery(target.workspace);
|
|
70
|
+
const navigate = navQuery ? FOCUS_WORKSPACE_STEPS : '';
|
|
71
|
+
// Paste beats keystroke for long/multibyte prompts. Stash the clipboard,
|
|
72
|
+
// focus the target workspace, paste, send, and restore.
|
|
73
|
+
const script = `
|
|
74
|
+
set savedClipboard to the clipboard
|
|
75
|
+
tell application "Conductor" to activate
|
|
76
|
+
delay 0.4
|
|
77
|
+
tell application "System Events"${navigate}
|
|
78
|
+
set the clipboard to (do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
|
|
79
|
+
keystroke "v" using {command down}
|
|
80
|
+
delay 0.15
|
|
81
|
+
key code 36
|
|
82
|
+
end tell
|
|
83
|
+
delay 0.1
|
|
84
|
+
set the clipboard to savedClipboard
|
|
85
|
+
`.trim();
|
|
86
|
+
// Pass the prompt via a temp file + env to avoid AppleScript string escaping.
|
|
87
|
+
const os = await import('node:os');
|
|
88
|
+
const fs = await import('node:fs/promises');
|
|
89
|
+
const path = await import('node:path');
|
|
90
|
+
const tmp = path.join(os.tmpdir(), `relay-prompt-${process.pid}-${Date.now()}.txt`);
|
|
91
|
+
await fs.writeFile(tmp, text, 'utf8');
|
|
92
|
+
try {
|
|
93
|
+
await exec('osascript', ['-e', script], {
|
|
94
|
+
env: { ...process.env, RELAY_PROMPT_FILE: tmp, RELAY_WS_QUERY: navQuery },
|
|
95
|
+
timeout: 15000
|
|
96
|
+
});
|
|
97
|
+
return { ok: true, strategy: this.name };
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
strategy: this.name,
|
|
103
|
+
error: err instanceof Error ? err.message : String(err)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Open a new chat in the target workspace — Conductor's "New chat, same files"
|
|
113
|
+
* (Cmd+T). Focuses the workspace first (command palette → branch), then Cmd+T; the
|
|
114
|
+
* caller detects the freshly-created session id from the DB.
|
|
115
|
+
*/
|
|
116
|
+
export async function newChat(workspace) {
|
|
117
|
+
const navQuery = focusQuery(workspace);
|
|
118
|
+
if (!navQuery)
|
|
119
|
+
return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
|
|
120
|
+
const script = `
|
|
121
|
+
tell application "Conductor" to activate
|
|
122
|
+
delay 0.4
|
|
123
|
+
tell application "System Events"${FOCUS_WORKSPACE_STEPS}
|
|
124
|
+
keystroke "t" using {command down}
|
|
125
|
+
end tell`.trim();
|
|
126
|
+
try {
|
|
127
|
+
await exec('osascript', ['-e', script], {
|
|
128
|
+
env: { ...process.env, RELAY_WS_QUERY: navQuery },
|
|
129
|
+
timeout: 15000
|
|
130
|
+
});
|
|
131
|
+
return { ok: true, strategy: 'applescript' };
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
return { ok: false, strategy: 'applescript', error: err instanceof Error ? err.message : String(err) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function pickActuator(strategy) {
|
|
138
|
+
return strategy === 'sidecar' ? new SidecarActuator() : new AppleScriptActuator();
|
|
139
|
+
}
|
|
140
|
+
/** Effective actuator description for the UI, factoring in runtime availability. */
|
|
141
|
+
export async function describeActuator(actuator) {
|
|
142
|
+
const available = actuator.available ? await actuator.available().catch(() => false) : true;
|
|
143
|
+
return { name: actuator.name, caveat: actuator.caveat, precise: actuator.precise, available };
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|
|
@@ -29,9 +29,8 @@
|
|
|
29
29
|
"bin": "bin/cli.js",
|
|
30
30
|
"files": [
|
|
31
31
|
"bin",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"dist"
|
|
32
|
+
"dist",
|
|
33
|
+
"dist-node"
|
|
35
34
|
],
|
|
36
35
|
"publishConfig": {
|
|
37
36
|
"access": "public",
|
|
@@ -43,6 +42,7 @@
|
|
|
43
42
|
"dev": "node scripts/dev.ts",
|
|
44
43
|
"dev:web": "vite",
|
|
45
44
|
"build": "vite build",
|
|
45
|
+
"build:node": "tsc -p tsconfig.build.json",
|
|
46
46
|
"preview": "yarn build && yarn start",
|
|
47
47
|
"gen:icons": "node scripts/gen-icons.ts",
|
|
48
48
|
"deploy": "yarn build && node bin/cli.js service install",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"fix": "biome check . --fix",
|
|
53
53
|
"verify": "yarn typecheck && yarn lint",
|
|
54
54
|
"release": "semantic-release",
|
|
55
|
-
"prepack": "yarn build",
|
|
55
|
+
"prepack": "yarn build && yarn build:node",
|
|
56
56
|
"postinstall": "husky || true"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
package/scripts/dev.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process'
|
|
2
|
-
import net from 'node:net'
|
|
3
|
-
|
|
4
|
-
// Run the Vite dev server (HMR PWA) and the relay (API + reads) together.
|
|
5
|
-
// Vite proxies /api → the relay so the phone hits a single origin.
|
|
6
|
-
//
|
|
7
|
-
// Dev-only per-workspace ports: inside a Conductor workspace, $CONDUCTOR_PORT is
|
|
8
|
-
// unique per workspace, so several `yarn dev`s can run concurrently. Vite (the
|
|
9
|
-
// origin a browser/phone opens) takes that port; the relay gets a free ephemeral
|
|
10
|
-
// port and Vite proxies /api to it on loopback. Outside Conductor, fall back to
|
|
11
|
-
// the classic 5173 (web) / 8787 (relay) pair. Prod (`yarn start`/`deploy`/the
|
|
12
|
-
// LaunchAgent) never runs through here, so its Tailscale bind and RELAY_PORT
|
|
13
|
-
// default stay untouched — this clutters neither the prod path nor the build.
|
|
14
|
-
|
|
15
|
-
/** Ask the OS for an unused TCP port so a Conductor workspace's relay never collides. */
|
|
16
|
-
function freePort(): Promise<number> {
|
|
17
|
-
return new Promise((resolve, reject) => {
|
|
18
|
-
const srv = net.createServer()
|
|
19
|
-
srv.once('error', reject)
|
|
20
|
-
srv.listen(0, '127.0.0.1', () => {
|
|
21
|
-
const { port } = srv.address() as net.AddressInfo
|
|
22
|
-
srv.close(() => resolve(port))
|
|
23
|
-
})
|
|
24
|
-
})
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const conductorPort = Number(process.env.CONDUCTOR_PORT) || 0
|
|
28
|
-
const webPort = conductorPort || 5173
|
|
29
|
-
// Loopback bind + Vite proxy target must match; only prod auto-binds the Tailscale NIC.
|
|
30
|
-
const relayPort = conductorPort ? await freePort() : 8787
|
|
31
|
-
|
|
32
|
-
const procs = [
|
|
33
|
-
{
|
|
34
|
-
name: 'web ',
|
|
35
|
-
cmd: 'yarn',
|
|
36
|
-
args: ['dev:web'],
|
|
37
|
-
color: '\x1b[35m',
|
|
38
|
-
env: { ...process.env, WEB_PORT: String(webPort), RELAY_PORT: String(relayPort) }
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
name: 'relay',
|
|
42
|
-
cmd: 'node',
|
|
43
|
-
args: ['--watch', 'bin/cli.js'],
|
|
44
|
-
color: '\x1b[36m',
|
|
45
|
-
env: { ...process.env, RELAY_PORT: String(relayPort), RELAY_HOST: process.env.RELAY_HOST ?? '127.0.0.1' }
|
|
46
|
-
}
|
|
47
|
-
]
|
|
48
|
-
|
|
49
|
-
const children = procs.map(p => {
|
|
50
|
-
const child = spawn(p.cmd, p.args, { stdio: ['inherit', 'pipe', 'pipe'], env: p.env })
|
|
51
|
-
const tag = `${p.color}[${p.name}]\x1b[0m `
|
|
52
|
-
const pipe = (stream: NodeJS.ReadableStream) => {
|
|
53
|
-
let buf = ''
|
|
54
|
-
stream.on('data', (d: Buffer) => {
|
|
55
|
-
buf += d.toString()
|
|
56
|
-
let nl = buf.indexOf('\n')
|
|
57
|
-
while (nl >= 0) {
|
|
58
|
-
console.log(tag + buf.slice(0, nl))
|
|
59
|
-
buf = buf.slice(nl + 1)
|
|
60
|
-
nl = buf.indexOf('\n')
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
pipe(child.stdout)
|
|
65
|
-
pipe(child.stderr)
|
|
66
|
-
return child
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
const shutdown = () => {
|
|
70
|
-
for (const c of children) c.kill('SIGTERM')
|
|
71
|
-
process.exit(0)
|
|
72
|
-
}
|
|
73
|
-
process.on('SIGINT', shutdown)
|
|
74
|
-
process.on('SIGTERM', shutdown)
|
|
75
|
-
for (const c of children) c.on('exit', shutdown)
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// ── Conductor IPC reconnaissance snippet ──────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// PREREQUISITE (read FINDINGS.md first): this only works if Conductor's WKWeb
|
|
4
|
-
// view is inspectable. On the shipped build it is NOT — the app is hardened/
|
|
5
|
-
// notarized without `get-task-allow`, so Safari's Develop menu won't list it
|
|
6
|
-
// and there is no console to paste this into. Injection is therefore blocked;
|
|
7
|
-
// the relay drives writes via Accessibility instead. Keep this snippet for the
|
|
8
|
-
// case where a future build ships with devtools enabled.
|
|
9
|
-
//
|
|
10
|
-
// IF a console is available (Safari ▸ Develop ▸ [Conductor] ▸ [webview]):
|
|
11
|
-
// paste this, then perform the action you want to reverse-engineer (send a
|
|
12
|
-
// prompt, approve a tool, stop a session). Every Tauri IPC call is logged with
|
|
13
|
-
// its command name + payload — that is the undocumented write API.
|
|
14
|
-
|
|
15
|
-
;(() => {
|
|
16
|
-
const internals = window.__TAURI_INTERNALS__
|
|
17
|
-
if (!internals || typeof internals.invoke !== 'function') {
|
|
18
|
-
console.warn('[recon] __TAURI_INTERNALS__.invoke not found — not a Tauri webview or not exposed.')
|
|
19
|
-
return
|
|
20
|
-
}
|
|
21
|
-
if (internals.__reconWrapped) {
|
|
22
|
-
console.info('[recon] already wrapped.')
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
const original = internals.invoke.bind(internals)
|
|
26
|
-
const log = []
|
|
27
|
-
window.__reconLog = log
|
|
28
|
-
internals.invoke = (cmd, payload, options) => {
|
|
29
|
-
// Stock Tauri plugin traffic is noise; Conductor's own commands are the signal.
|
|
30
|
-
const interesting = !String(cmd).startsWith('plugin:')
|
|
31
|
-
const record = { t: new Date().toISOString(), cmd, payload }
|
|
32
|
-
if (interesting) {
|
|
33
|
-
log.push(record)
|
|
34
|
-
console.info('%c[INVOKE]', 'color:#4a9eff', cmd, payload)
|
|
35
|
-
}
|
|
36
|
-
return original(cmd, payload, options)
|
|
37
|
-
}
|
|
38
|
-
internals.__reconWrapped = true
|
|
39
|
-
console.info(
|
|
40
|
-
'[recon] invoke wrapped. Reproduce an action, then run: copy(JSON.stringify(window.__reconLog, null, 2))'
|
|
41
|
-
)
|
|
42
|
-
})()
|
package/scripts/gen-icons.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import sharp from 'sharp'
|
|
4
|
-
|
|
5
|
-
// Rasterize web/public/icon.svg into the PNG sizes the PWA manifest + iOS need.
|
|
6
|
-
const pub = path.join(import.meta.dirname, '..', 'web', 'public')
|
|
7
|
-
const svg = await readFile(path.join(pub, 'icon.svg'))
|
|
8
|
-
|
|
9
|
-
const targets: { name: string; size: number; background?: string }[] = [
|
|
10
|
-
{ name: 'icon-192.png', size: 192 },
|
|
11
|
-
{ name: 'icon-512.png', size: 512 },
|
|
12
|
-
{ name: 'icon-maskable-512.png', size: 512 },
|
|
13
|
-
{ name: 'apple-touch-icon.png', size: 180, background: '#0a0b0e' }
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
for (const t of targets) {
|
|
17
|
-
let pipe = sharp(svg, { density: 384 }).resize(t.size, t.size, { fit: 'contain' })
|
|
18
|
-
if (t.background) pipe = pipe.flatten({ background: t.background })
|
|
19
|
-
const out = await pipe.png().toBuffer()
|
|
20
|
-
await writeFile(path.join(pub, t.name), out)
|
|
21
|
-
console.info(`wrote ${t.name} (${t.size}px)`)
|
|
22
|
-
}
|