conductor-remote 1.79.0 → 1.81.0

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.
@@ -0,0 +1,283 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ const MAX_PREVIEWS = 10;
5
+ function validPort(value) {
6
+ return Number.isInteger(value) && value > 0 && value <= 65535;
7
+ }
8
+ /** Remove a TOML comment without treating a `#` inside a quoted string as one. */
9
+ function withoutComment(line) {
10
+ let quote = null;
11
+ let escaped = false;
12
+ for (let i = 0; i < line.length; i++) {
13
+ const char = line[i];
14
+ if (quote === '"' && escaped) {
15
+ escaped = false;
16
+ continue;
17
+ }
18
+ if (quote === '"' && char === '\\') {
19
+ escaped = true;
20
+ continue;
21
+ }
22
+ if (quote) {
23
+ if (char === quote)
24
+ quote = null;
25
+ continue;
26
+ }
27
+ if (char === '"' || char === "'")
28
+ quote = char;
29
+ else if (char === '#')
30
+ return line.slice(0, i);
31
+ }
32
+ return line;
33
+ }
34
+ function tomlString(raw) {
35
+ const value = withoutComment(raw).trim();
36
+ if (value.startsWith("'") && value.endsWith("'"))
37
+ return value.slice(1, -1);
38
+ if (!(value.startsWith('"') && value.endsWith('"')))
39
+ return null;
40
+ try {
41
+ return JSON.parse(value);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ function fields(text) {
48
+ const found = {};
49
+ for (const line of text.split('\n')) {
50
+ const match = withoutComment(line).match(/^\s*(name|url)\s*=\s*(.+?)\s*$/);
51
+ if (!match)
52
+ continue;
53
+ const value = tomlString(match[2]);
54
+ if (value !== null)
55
+ found[match[1]] = value;
56
+ }
57
+ return found;
58
+ }
59
+ /**
60
+ * Split an inline TOML table on top-level commas. Preview URL fields are strings,
61
+ * so this deliberately implements only the small grammar the Conductor schema
62
+ * permits instead of pulling a TOML runtime into the dependency-free relay.
63
+ */
64
+ function inlineFields(text) {
65
+ const parts = [];
66
+ let start = 0;
67
+ let quote = null;
68
+ let escaped = false;
69
+ for (let i = 0; i < text.length; i++) {
70
+ const char = text[i];
71
+ if (quote === '"' && escaped) {
72
+ escaped = false;
73
+ continue;
74
+ }
75
+ if (quote === '"' && char === '\\') {
76
+ escaped = true;
77
+ continue;
78
+ }
79
+ if (quote) {
80
+ if (char === quote)
81
+ quote = null;
82
+ continue;
83
+ }
84
+ if (char === '"' || char === "'")
85
+ quote = char;
86
+ else if (char === ',') {
87
+ parts.push(text.slice(start, i));
88
+ start = i + 1;
89
+ }
90
+ }
91
+ parts.push(text.slice(start));
92
+ return fields(parts.join('\n'));
93
+ }
94
+ /** Extract a balanced TOML array, ignoring brackets inside strings and comments. */
95
+ function arrayAt(text, start) {
96
+ let depth = 0;
97
+ let quote = null;
98
+ let escaped = false;
99
+ let comment = false;
100
+ for (let i = start; i < text.length; i++) {
101
+ const char = text[i];
102
+ if (comment) {
103
+ if (char === '\n')
104
+ comment = false;
105
+ continue;
106
+ }
107
+ if (quote === '"' && escaped) {
108
+ escaped = false;
109
+ continue;
110
+ }
111
+ if (quote === '"' && char === '\\') {
112
+ escaped = true;
113
+ continue;
114
+ }
115
+ if (quote) {
116
+ if (char === quote)
117
+ quote = null;
118
+ continue;
119
+ }
120
+ if (char === '#')
121
+ comment = true;
122
+ else if (char === '"' || char === "'")
123
+ quote = char;
124
+ else if (char === '[')
125
+ depth++;
126
+ else if (char === ']' && --depth === 0)
127
+ return { body: text.slice(start + 1, i), end: i + 1 };
128
+ }
129
+ return null;
130
+ }
131
+ function inlineTables(body) {
132
+ const tables = [];
133
+ let quote = null;
134
+ let escaped = false;
135
+ let comment = false;
136
+ let start = -1;
137
+ for (let i = 0; i < body.length; i++) {
138
+ const char = body[i];
139
+ if (comment) {
140
+ if (char === '\n')
141
+ comment = false;
142
+ continue;
143
+ }
144
+ if (quote === '"' && escaped) {
145
+ escaped = false;
146
+ continue;
147
+ }
148
+ if (quote === '"' && char === '\\') {
149
+ escaped = true;
150
+ continue;
151
+ }
152
+ if (quote) {
153
+ if (char === quote)
154
+ quote = null;
155
+ continue;
156
+ }
157
+ if (char === '#')
158
+ comment = true;
159
+ else if (char === '"' || char === "'")
160
+ quote = char;
161
+ else if (char === '{')
162
+ start = i + 1;
163
+ else if (char === '}' && start >= 0) {
164
+ tables.push(inlineFields(body.slice(start, i)));
165
+ start = -1;
166
+ }
167
+ }
168
+ return tables;
169
+ }
170
+ /**
171
+ * Read just Conductor's `preview_urls` setting from TOML. Conductor serializes
172
+ * these as `[[preview_urls]]`; the inline-array form is accepted as well because
173
+ * it is equally valid against the public schema. `null` means the layer did not
174
+ * set the key, while `[]` is an explicit override.
175
+ */
176
+ export function parsePreviewUrlsToml(text) {
177
+ const tables = [];
178
+ let seen = false;
179
+ const headers = [...text.matchAll(/^\s*\[\[\s*preview_urls\s*\]\]\s*(?:#.*)?$/gm)];
180
+ for (const [index, header] of headers.entries()) {
181
+ seen = true;
182
+ const start = (header.index ?? 0) + header[0].length;
183
+ const nextHeader = text.slice(start).search(/^\s*\[{1,2}[^\n]+\]{1,2}\s*(?:#.*)?$/m);
184
+ const end = nextHeader < 0 ? text.length : start + nextHeader;
185
+ tables.push(fields(text.slice(start, end)));
186
+ if (index >= MAX_PREVIEWS - 1)
187
+ break;
188
+ }
189
+ // A root inline array has to appear before the first TOML table; after a table
190
+ // header, an unqualified key belongs to that table rather than to the root.
191
+ const firstTable = text.search(/^\s*\[/m);
192
+ const root = firstTable < 0 ? text : text.slice(0, firstTable);
193
+ const inline = root.match(/^\s*preview_urls\s*=\s*\[/m);
194
+ if (inline?.index !== undefined) {
195
+ seen = true;
196
+ const open = inline.index + inline[0].lastIndexOf('[');
197
+ const array = arrayAt(root, open);
198
+ if (array)
199
+ tables.unshift(...inlineTables(array.body));
200
+ }
201
+ if (!seen)
202
+ return null;
203
+ return tables
204
+ .flatMap(entry => (typeof entry.url === 'string' ? [{ name: entry.name, url: entry.url }] : []))
205
+ .slice(0, MAX_PREVIEWS);
206
+ }
207
+ const fileCache = new Map();
208
+ function readLayer(file) {
209
+ try {
210
+ const stat = fs.statSync(file);
211
+ const cached = fileCache.get(file);
212
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size)
213
+ return cached.value;
214
+ const value = parsePreviewUrlsToml(fs.readFileSync(file, 'utf8'));
215
+ fileCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, value });
216
+ return value;
217
+ }
218
+ catch {
219
+ fileCache.delete(file);
220
+ return null;
221
+ }
222
+ }
223
+ /** Resolve Conductor's user → repository → repository-local → managed precedence. */
224
+ export function previewUrlSettings(workspace) {
225
+ let resolved = readLayer(path.join(os.homedir(), '.conductor', 'settings.toml')) ?? [];
226
+ const shared = workspace.worktree
227
+ ? path.join(workspace.worktree, '.conductor', 'settings.toml')
228
+ : workspace.repo_root
229
+ ? path.join(workspace.repo_root, '.conductor', 'settings.toml')
230
+ : null;
231
+ if (shared)
232
+ resolved = readLayer(shared) ?? resolved;
233
+ // Machine-local repository settings live in the main checkout and outrank the
234
+ // shared file. A copied worktree-local file wins when one intentionally exists.
235
+ for (const file of [
236
+ workspace.repo_root ? path.join(workspace.repo_root, '.conductor', 'settings.local.toml') : null,
237
+ workspace.worktree ? path.join(workspace.worktree, '.conductor', 'settings.local.toml') : null
238
+ ]) {
239
+ if (!file)
240
+ continue;
241
+ resolved = readLayer(file) ?? resolved;
242
+ }
243
+ resolved = readLayer(path.join(os.homedir(), '.conductor', 'settings.managed.toml')) ?? resolved;
244
+ return resolved;
245
+ }
246
+ /** Expand the supported Conductor template and retain only loopback HTTP servers. */
247
+ export function resolvePreviewTargets(settings, conductorPort) {
248
+ const targets = [];
249
+ const seen = new Set();
250
+ for (const setting of settings) {
251
+ let value = setting.url;
252
+ if (/\$(?:CONDUCTOR_PORT\b|\{CONDUCTOR_PORT\})/.test(value)) {
253
+ if (!conductorPort)
254
+ continue;
255
+ value = value.replace(/\$(?:CONDUCTOR_PORT\b|\{CONDUCTOR_PORT\})/g, String(conductorPort));
256
+ }
257
+ // Other Conductor variables can describe paths, but forwarding an unresolved
258
+ // template would point at a URL different from the one Conductor opens.
259
+ if (/\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/.test(value))
260
+ continue;
261
+ try {
262
+ const url = new URL(value);
263
+ if (url.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
264
+ continue;
265
+ const port = Number(url.port || 80);
266
+ if (!validPort(port))
267
+ continue;
268
+ const previewPath = `${url.pathname || '/'}${url.search}${url.hash}`;
269
+ const key = `${port}\0${previewPath}`;
270
+ if (seen.has(key))
271
+ continue;
272
+ seen.add(key);
273
+ targets.push({ name: setting.name?.trim() || `Port ${port}`, port, path: previewPath });
274
+ if (targets.length >= MAX_PREVIEWS)
275
+ break;
276
+ }
277
+ catch {
278
+ // Invalid settings are already surfaced by Conductor's schema UI. They are
279
+ // not a reason for the relay to guess at a different host or port.
280
+ }
281
+ }
282
+ return targets;
283
+ }
@@ -193,31 +193,35 @@ export class Reads {
193
193
  return out;
194
194
  }
195
195
  /**
196
- * Every chat in the named repos, archived workspaces included — the repo filter
197
- * as the search index needs it, since its chunks carry a chat id and nothing else.
198
- * Names, not ids, because names are what the phone's picker and `list_repos` hold.
196
+ * Every chat in the requested search scope. The index only carries chat ids, so
197
+ * repo and archive filters have to be resolved here before ranking. Names, not repo
198
+ * ids, because names are what the phone's picker and `list_repos` hold.
199
199
  */
200
- sessionIdsInRepos(names) {
201
- if (!names.length)
200
+ searchSessionIds(repos, includeArchived) {
201
+ if (repos && !repos.length)
202
202
  return [];
203
- const holes = names.map(() => '?').join(',');
203
+ const scope = [
204
+ ...(repos ? [`r.name IN (${repos.map(() => '?').join(',')})`] : []),
205
+ ...(!includeArchived ? ["w.state IS NOT 'archived'"] : [])
206
+ ];
204
207
  return this.db
205
208
  .query(`SELECT s.id FROM sessions s
206
209
  JOIN workspaces w ON w.id = s.workspace_id
207
- JOIN repos r ON r.id = w.repository_id
208
- WHERE r.name IN (${holes})`, names)
210
+ LEFT JOIN repos r ON r.id = w.repository_id
211
+ ${scope.length ? ` WHERE ${scope.join(' AND ')}` : ''}`, repos ?? [])
209
212
  .map(r => r.id);
210
213
  }
211
214
  /**
212
215
  * Workspaces whose own identity matches every token — name, PR title, branch,
213
- * worktree codename or repo. Archived included, same reason as `searchTargets`.
214
- * `repos` narrows to those repos by name; an empty list matches nothing.
216
+ * worktree codename or repo. Archived are included by default, for the same reason
217
+ * as `searchTargets`; `includeArchived` lets the phone narrow the search to current
218
+ * work. `repos` narrows to those repos by name; an empty list matches nothing.
215
219
  *
216
220
  * This is the half of search the transcript index cannot do. A workspace named for
217
221
  * the thing you are looking for may never have said those words in its chat, and
218
222
  * one whose chat is empty has no chunks at all.
219
223
  */
220
- findWorkspacesByName(tokens, limit = 20, repos) {
224
+ findWorkspacesByName(tokens, limit = 20, repos, includeArchived = true) {
221
225
  if (!tokens.length)
222
226
  return [];
223
227
  if (repos && !repos.length)
@@ -226,7 +230,10 @@ export class Reads {
226
230
  // AND across tokens, OR across fields: "auk lamp" should find the lamp workspace in
227
231
  // the auk repo, where no single column holds both words.
228
232
  const byToken = tokens.map(() => `(${fields.map(f => `${f} LIKE ? ESCAPE '\\'`).join(' OR ')})`);
229
- const scope = repos ? [`r.name IN (${repos.map(() => '?').join(',')})`] : [];
233
+ const scope = [
234
+ ...(repos ? [`r.name IN (${repos.map(() => '?').join(',')})`] : []),
235
+ ...(!includeArchived ? ["w.state IS NOT 'archived'"] : [])
236
+ ];
230
237
  const where = [...byToken, ...scope].join(' AND ');
231
238
  const params = [...tokens.flatMap(t => fields.map(() => `%${escapeLike(t)}%`)), ...(repos ?? [])];
232
239
  const rows = this.db.query(`SELECT w.id, w.workspace_name, w.pr_title, w.branch, w.directory_name, w.state, w.updated_at,
@@ -62,6 +62,12 @@ export const routes = {
62
62
  /** Drop a staged file the user removed before creating its workspace. */
63
63
  discardStagedAttachment: param('DELETE', '/api/attachments/:attachmentId'),
64
64
  logs: flat('GET', '/api/logs'),
65
+ /**
66
+ * Quit Conductor and start it again. Not a workspace route: it is about the app,
67
+ * and its whole reason to exist is a Conductor that looks healthy from every other
68
+ * route while nothing behind it runs (src/writes.ts ▸ restartConductorApp).
69
+ */
70
+ restartConductor: flat('POST', '/api/conductor/restart'),
65
71
  settings: flat('GET', '/api/settings'),
66
72
  updateSettings: flat('PATCH', '/api/settings'),
67
73
  /** Durable, device-independent PWA state. localStorage remains its offline-first mirror. */
@@ -32,7 +32,7 @@ import { discardStagedAttachment, materializeStagedAttachments, stageAttachment,
32
32
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
33
33
  import { renderTranscript, transcriptThrough } from "./transcript.js";
34
34
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
35
- import { archiveWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
35
+ import { archiveWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
36
36
  // Before anything that logs: from here on every console line is also kept in memory for
37
37
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
38
38
  installLogCapture();
@@ -824,12 +824,14 @@ const server = http.createServer(async (req, res) => {
824
824
  // Both reach archived workspaces. That is the point: 1,846 of the 1,886 here are
825
825
  // archived, so a search limited to the live sidebar would miss almost everything.
826
826
  //
827
- // `repo=` (repeatable) scopes both halves to those repos. It is resolved to chat
828
- // ids and pushed *into* the FTS query rather than applied to its top 300 chunks,
829
- // or a common word would fill every slot from the busiest repo (search.ts ▸ search).
827
+ // `repo=` (repeatable) and `archived=0` scope both halves. They are resolved to
828
+ // chat ids and pushed *into* the FTS query rather than applied to its top 300
829
+ // chunks, or excluded work would fill every slot (search.ts ▸ search).
830
830
  if (isRoute(routes.search, req.method, pathname)) {
831
831
  const q = url.searchParams.get('q') ?? '';
832
832
  const repos = [...new Set(url.searchParams.getAll('repo').filter(Boolean))];
833
+ // Archived search predates the toggle and stays the default for cached PWAs and MCP.
834
+ const includeArchived = url.searchParams.get('archived') !== '0';
833
835
  // 12, not 50: an OR query over common words ("add", "remove") has a long weak tail,
834
836
  // and past the first screenful nobody scrolls — they retype instead.
835
837
  const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 12) || 12));
@@ -837,13 +839,19 @@ const server = http.createServer(async (req, res) => {
837
839
  const tokens = queryTokens(q);
838
840
  if (!tokens.length)
839
841
  return json(req, res, 200, { query: q, repos, results: [], index });
840
- const scope = repos.length ? { sessionIds: reads.sessionIdsInRepos(repos) } : {};
842
+ const scoped = repos.length > 0 || !includeArchived;
843
+ const scope = scoped
844
+ ? { sessionIds: reads.searchSessionIds(repos.length ? repos : undefined, includeArchived) }
845
+ : {};
841
846
  const hits = search.search(q, scope);
842
847
  const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
843
- const fromChats = foldHits(hits, sid => targets.get(sid)?.workspace ?? null);
848
+ const fromChats = foldHits(hits, sid => {
849
+ const workspace = targets.get(sid)?.workspace ?? null;
850
+ return !includeArchived && workspace?.archived ? null : workspace;
851
+ });
844
852
  const remaining = new Map(fromChats.map(r => [r.workspace.id, r]));
845
853
  const merged = [];
846
- for (const workspace of reads.findWorkspacesByName(tokens, limit, repos.length ? repos : undefined)) {
854
+ for (const workspace of reads.findWorkspacesByName(tokens, limit, repos.length ? repos : undefined, includeArchived)) {
847
855
  const evidence = remaining.get(workspace.id);
848
856
  remaining.delete(workspace.id);
849
857
  // Keep the chat evidence when there is any: the snippet is what tells you this
@@ -957,6 +965,36 @@ const server = http.createServer(async (req, res) => {
957
965
  const result = await disarmNoSleep();
958
966
  return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
959
967
  }
968
+ // POST /api/conductor/restart { stopAgents? } — quit Conductor and start it again.
969
+ //
970
+ // The lever exists in the actuator already, but only as activateConductor's last
971
+ // resort, which fires exclusively for a *windowless* Conductor. This is for the
972
+ // other shape: window up, prompts landing as rows, and no agent output behind any
973
+ // of it (measured 2026-09-02 — 2h35m of user rows after the last agent frame).
974
+ // The running agents are counted from the DB before the UI is touched and refused
975
+ // unless the caller meant it, the same way archiving is: quitting ends every turn
976
+ // in flight. The lock screen is the actuator's own gate, since only it can ask.
977
+ if (isRoute(routes.restartConductor, req.method, pathname)) {
978
+ const body = JSON.parse((await readBody(req)) || '{}');
979
+ const working = reads.listSessionStates().filter(state => state.status === 'working').length;
980
+ if (working > 0 && body.stopAgents !== true) {
981
+ return json(req, res, 409, {
982
+ ok: false,
983
+ agentsRunning: true,
984
+ working,
985
+ error: `${working} chat${working === 1 ? ' is' : 's are'} mid-turn. Restarting Conductor ends ${working === 1 ? 'it' : 'them'}.`
986
+ });
987
+ }
988
+ const startedAt = Date.now();
989
+ const result = await restartConductorApp();
990
+ const ms = Date.now() - startedAt;
991
+ if (!result.ok) {
992
+ console.warn(`[restart] Conductor restart failed after ${(ms / 1000).toFixed(1)}s: ${result.error}`);
993
+ return json(req, res, 502, { ok: false, ms, error: result.error });
994
+ }
995
+ console.log(`[restart] quit Conductor and relaunched it in ${(ms / 1000).toFixed(1)}s`);
996
+ return json(req, res, 200, { ok: true, ms });
997
+ }
960
998
  // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
961
999
  // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
962
1000
  // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
@@ -103,6 +103,13 @@ export function uiTurn(op) {
103
103
  * a doomed one waits it out, and the caller's own deadline is what bounds that.
104
104
  */
105
105
  export const SEND_ATTEMPT_MS = 28_000;
106
+ /**
107
+ * A restart's own ceiling. It is longer than a send's because it is mostly *waiting*:
108
+ * up to 4s for the quit to be honoured, then a cold launch, then `waitForWindow(60)`'s
109
+ * 15s for the first window — none of which can be hurried, and all of which the caller
110
+ * would rather wait through than be told "try again" about.
111
+ */
112
+ export const RESTART_ATTEMPT_MS = 45_000;
106
113
  /**
107
114
  * A run's own ceiling, taken off the caller's deadline at the moment it actually
108
115
  * starts.
@@ -649,6 +656,42 @@ return "ok"`.trim();
649
656
  return { ok: false, strategy: 'applescript', error: osaError(err) };
650
657
  }
651
658
  }
659
+ /**
660
+ * Quit Conductor and start it again because the phone asked — the one write here
661
+ * whose subject is the app rather than anything inside it.
662
+ *
663
+ * The lever already existed and was unreachable. `activateConductor` restarts as its
664
+ * last resort, but only after a *running* Conductor has drawn no window through
665
+ * `reopen` and a Dock click, so it answers exactly one failure: a windowless app. The
666
+ * failure this is for looks healthy from every probe on that ladder — window up,
667
+ * sidebar drawing, composer taking prompts — while the agent runtime behind it has
668
+ * stopped producing anything. Measured on this Mac (2026-09-02): the last agent frame
669
+ * in `session_messages` was 20:47:44 and prompts kept landing as user rows for the
670
+ * next two and a half hours, each turn flipping `working → idle` having written
671
+ * nothing. Nothing on the read side can fix that, and "quit it on your Mac" is not
672
+ * advice a phone can act on.
673
+ *
674
+ * Two gates, and neither lives here. The **working chats** are counted from the DB by
675
+ * server.ts, which refuses without `stopAgents` — quitting takes every agent mid-turn
676
+ * down with it, so that has to be said out loud, exactly as it is for archiving. The
677
+ * **lock screen** is asked by `restartApp` itself, because a relaunch fired behind it
678
+ * comes up windowless (and once, wedged). What is left for this function is the UI
679
+ * lock: a restart is not a read, and letting one land while a send is mid-flight would
680
+ * quit the app between the composer write and the Enter.
681
+ */
682
+ export async function restartConductorApp() {
683
+ const script = `
684
+ ${CONDUCTOR_HANDLERS}
685
+
686
+ return my restartApp()`.trim();
687
+ try {
688
+ await uiTurn(() => exec('osascript', ['-e', script], { env: { ...process.env }, timeout: RESTART_ATTEMPT_MS }));
689
+ return { ok: true, strategy: 'applescript' };
690
+ }
691
+ catch (err) {
692
+ return { ok: false, strategy: 'applescript', error: osaError(err, 'Conductor didn’t come back in time') };
693
+ }
694
+ }
652
695
  /**
653
696
  * The workspace statuses Conductor's sidebar groups by, mapped from the value it
654
697
  * stores in `workspaces.manual_status` to the label on its own menu. `canceled`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.79.0",
3
+ "version": "1.81.0",
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.",