conductor-remote 1.78.1 → 1.80.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.
package/dist/index.html CHANGED
@@ -13,8 +13,8 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-dOiHAzne.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-BoeZwP2N.css">
16
+ <script type="module" crossorigin src="/assets/index-ILQ9q-V7.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-DaafT1eA.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
20
20
  <div id="root"></div>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let c={};const l=e=>i(e,o),t={module:{uri:o},exports:c,require:l};s[o]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(r(...e),c))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"bc2a0334dcf9d3a15034a75c866b2e57"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-dOiHAzne.js",revision:null},{url:"assets/index-BoeZwP2N.css",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"4fa6b9e7cf25227330786aa264ae4ce6"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-ILQ9q-V7.js",revision:null},{url:"assets/index-DaafT1eA.css",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -293,6 +293,37 @@ on restartConductor()
293
293
  return ""
294
294
  end restartConductor
295
295
 
296
+ on restartApp()
297
+ -- The restart the phone asks for (POST /api/conductor/restart), as opposed to
298
+ -- the one activateConductor falls back to on its own. Same lever, different
299
+ -- entry, and the second entry is the whole point: that fallback fires only when
300
+ -- a *running* Conductor draws no window, so it is unreachable for the failure
301
+ -- this exists for — a Conductor whose window, sidebar and composer are all fine
302
+ -- while its agent runtime has stopped producing output. Measured live
303
+ -- (2026-09-02): prompts kept landing as `session_messages` rows for hours after
304
+ -- the last agent frame, so every window probe here would have said "healthy".
305
+ --
306
+ -- RELAY_ALLOW_RESTART is deliberately NOT consulted. That flag exists so the
307
+ -- fallback never destroys work the caller didn't ask about; here the caller is
308
+ -- a person who pressed a button, and server.ts has already counted the working
309
+ -- chats from the DB and refused without `stopAgents`.
310
+ set refusal to my refusalReason(my windowProbe())
311
+ if refusal is not "" then error refusal
312
+ -- Behind the lock screen a relaunch comes up windowless, and once came up
313
+ -- wedged (answering deep links, hanging every AX read) — the same reason the
314
+ -- fallback refuses there. The sentence starts with the phrase both sides match
315
+ -- on (MAC_LOCKED in src/shared.ts) so the phone draws its unlock link.
316
+ if my screenLocked() is "locked" then error "The Mac is locked - relaunching Conductor behind the lock screen is what leaves it wedged, so the relay won't. Unlock the Mac and try again." & my windowEvidence()
317
+ set restartError to my restartConductor()
318
+ if restartError is not "" then error restartError & my windowEvidence()
319
+ -- The fallback returns the moment the relaunch starts, because it is spending a
320
+ -- *send's* budget and a cold launch doesn't fit beside one. This run has nothing
321
+ -- else to do with its budget, so it waits for the window and the phone hears
322
+ -- what actually happened instead of "try again": ~5-10s for a cold launch here.
323
+ if not my waitForWindow(60) then error "Conductor was relaunched but hasn't drawn a window yet. Give it a few seconds, then try again." & my windowEvidence()
324
+ return "ok"
325
+ end restartApp
326
+
296
327
  on waitForWindow(attempts)
297
328
  -- "reopen" is the dock-click Apple event, and for most apps that is what
298
329
  -- recreates a closed window ("activate" on its own does not). Nudge a few times
@@ -1447,6 +1478,141 @@ on pickerShowsModel(popup, wanted)
1447
1478
  return false
1448
1479
  end pickerShowsModel
1449
1480
 
1481
+ on modelMenuItem(wanted)
1482
+ -- Resolve one model from the picker that is already open. The menu can append
1483
+ -- `NEW` to a freshly released model while the relay deliberately exposes the
1484
+ -- stable name without it, so the same unique-prefix rule as setModel applies.
1485
+ set chosen to missing value
1486
+ set loose to {}
1487
+ set wa to my webArea()
1488
+ tell application "System Events" to tell process "Conductor"
1489
+ repeat with m in (get UI elements of wa whose role is "AXMenu")
1490
+ repeat with mi in (get UI elements of m whose role is "AXMenuItem")
1491
+ set label to my firstLine(my tabLabel(mi))
1492
+ if label is wanted then
1493
+ set chosen to contents of mi
1494
+ else if label starts with wanted then
1495
+ set end of loose to contents of mi
1496
+ end if
1497
+ end repeat
1498
+ end repeat
1499
+ end tell
1500
+ if chosen is missing value and (count of loose) is 1 then set chosen to item 1 of loose
1501
+ if chosen is missing value then
1502
+ if (count of loose) > 1 then error "several models match " & wanted
1503
+ error "no model named " & wanted
1504
+ end if
1505
+ return chosen
1506
+ end modelMenuItem
1507
+
1508
+ on modelDefaultButton(menuItem)
1509
+ -- Every non-default row has a child star button named "Set <model> as
1510
+ -- default and select". The starred row has no button at all. Read the role and
1511
+ -- label together so an unrelated control a future picker grows cannot be
1512
+ -- mistaken for a global preference write.
1513
+ repeat with childNode in my axKids(menuItem)
1514
+ set childEl to contents of childNode
1515
+ if (my axRole(childEl)) is "AXButton" and (my axName(childEl)) contains " as default" then return childEl
1516
+ end repeat
1517
+ return missing value
1518
+ end modelDefaultButton
1519
+
1520
+ on modelMenuItems()
1521
+ set found to {}
1522
+ set wa to my webArea()
1523
+ tell application "System Events" to tell process "Conductor"
1524
+ repeat with m in (get UI elements of wa whose role is "AXMenu")
1525
+ set found to found & (get UI elements of m whose role is "AXMenuItem")
1526
+ end repeat
1527
+ end tell
1528
+ return found
1529
+ end modelMenuItems
1530
+
1531
+ on defaultModelInOpenPicker()
1532
+ -- The selected star is exposed by *removing* the star button from that row.
1533
+ -- Require exactly one such row. If managed settings disable every star, or a
1534
+ -- Conductor release changes this tree, failing closed is safer than reporting
1535
+ -- that whichever model was requested became the user's global default.
1536
+ set defaults to {}
1537
+ repeat with mi in my modelMenuItems()
1538
+ set menuItem to contents of mi
1539
+ if (my modelDefaultButton(menuItem)) is missing value then set end of defaults to my firstLine(my tabLabel(menuItem))
1540
+ end repeat
1541
+ if (count of defaults) is not 1 then error "couldn't identify Conductor's default model"
1542
+ return item 1 of defaults
1543
+ end defaultModelInOpenPicker
1544
+
1545
+ on modelPickerButton()
1546
+ set popup to missing value
1547
+ repeat with entry in my composerControls()
1548
+ set c to contents of entry
1549
+ if my tabLabel(c) contains "Change agent" then set popup to c
1550
+ end repeat
1551
+ if popup is missing value then error "couldn't find the model picker"
1552
+ return popup
1553
+ end modelPickerButton
1554
+
1555
+ on openModelPicker()
1556
+ set popup to my modelPickerButton()
1557
+ tell application "System Events" to tell process "Conductor"
1558
+ perform action "AXPress" of popup
1559
+ end tell
1560
+ delay 1.0
1561
+ end openModelPicker
1562
+
1563
+ on setDefaultModel(wanted)
1564
+ -- Conductor's star is one combined action: its accessible name is "Set … as
1565
+ -- default and select". Mirror that rather than pretending this only changes a
1566
+ -- preference — the target chat switches model too, and the picker label is
1567
+ -- read back below as the receipt for that half.
1568
+ set initialPopup to my modelPickerButton()
1569
+ set alreadySelected to my pickerShowsModel(initialPopup, wanted)
1570
+ tell application "System Events" to tell process "Conductor"
1571
+ perform action "AXPress" of initialPopup
1572
+ end tell
1573
+ delay 1.0
1574
+ try
1575
+ set chosen to my modelMenuItem(wanted)
1576
+ set chosenLabel to my firstLine(my tabLabel(chosen))
1577
+ set currentDefault to my defaultModelInOpenPicker()
1578
+ if currentDefault is chosenLabel then
1579
+ if alreadySelected then
1580
+ tell application "System Events" to key code 53
1581
+ return chosenLabel
1582
+ end if
1583
+ -- The star is already selected, but this chat is not. Its row is now
1584
+ -- the remaining half of Conductor's combined action.
1585
+ tell application "System Events" to tell process "Conductor"
1586
+ perform action "AXPress" of chosen
1587
+ end tell
1588
+ delay 0.8
1589
+ set selectedPopup to my modelPickerButton()
1590
+ if not my pickerShowsModel(selectedPopup, chosenLabel) then error "the model didn't switch to " & chosenLabel
1591
+ return chosenLabel
1592
+ end if
1593
+ set starButton to my modelDefaultButton(chosen)
1594
+ if starButton is missing value then error "the default-model star is missing for " & chosenLabel
1595
+ tell application "System Events" to tell process "Conductor"
1596
+ perform action "AXPress" of starButton
1597
+ end tell
1598
+ delay 0.8
1599
+ set selectedPopup to my modelPickerButton()
1600
+ if not my pickerShowsModel(selectedPopup, chosenLabel) then error "the model didn't switch to " & chosenLabel
1601
+ -- Re-open and read the star back. Pressing is fire-and-forget; the only safe
1602
+ -- success is the requested row becoming the unique one with no star button.
1603
+ my openModelPicker()
1604
+ set verified to my modelMenuItem(wanted)
1605
+ set verifiedLabel to my firstLine(my tabLabel(verified))
1606
+ set currentDefault to my defaultModelInOpenPicker()
1607
+ tell application "System Events" to key code 53
1608
+ if currentDefault is not verifiedLabel then error "Conductor didn't mark " & verifiedLabel & " as the default model"
1609
+ return verifiedLabel
1610
+ on error errText number errNum
1611
+ tell application "System Events" to key code 53
1612
+ error errText number errNum
1613
+ end try
1614
+ end setDefaultModel
1615
+
1450
1616
  on setModel(wanted)
1451
1617
  set popup to missing value
1452
1618
  repeat with entry in my composerControls()
@@ -1499,32 +1665,25 @@ end setModel
1499
1665
 
1500
1666
  on listModels()
1501
1667
  -- Enumerate the picker rather than hard-coding a model list that would rot on
1502
- -- every Conductor release. Opens the menu, reads the labels, closes it again.
1503
- set popup to missing value
1504
- repeat with entry in my composerControls()
1505
- set c to contents of entry
1506
- if my tabLabel(c) contains "Change agent" then set popup to c
1507
- end repeat
1508
- if popup is missing value then error "couldn't find the model picker"
1509
- tell application "System Events" to tell process "Conductor"
1510
- perform action "AXPress" of popup
1511
- end tell
1512
- delay 1.0
1513
- set labels to {}
1514
- set wa to my webArea()
1515
- tell application "System Events" to tell process "Conductor"
1516
- repeat with m in (get UI elements of wa whose role is "AXMenu")
1517
- repeat with mi in (get UI elements of m whose role is "AXMenuItem")
1518
- set end of labels to my firstLine(my tabLabel(mi))
1519
- end repeat
1668
+ -- every Conductor release. The first tagged line carries the unique starred
1669
+ -- row, followed by the ordinary labels. Opens the menu, reads, closes it again.
1670
+ my openModelPicker()
1671
+ try
1672
+ set labels to {}
1673
+ repeat with mi in my modelMenuItems()
1674
+ set end of labels to my firstLine(my tabLabel(mi))
1520
1675
  end repeat
1521
- end tell
1522
- tell application "System Events" to key code 53
1523
- set saved to AppleScript's text item delimiters
1524
- set AppleScript's text item delimiters to linefeed
1525
- set joined to labels as text
1526
- set AppleScript's text item delimiters to saved
1527
- return joined
1676
+ set defaultLabel to my defaultModelInOpenPicker()
1677
+ tell application "System Events" to key code 53
1678
+ set saved to AppleScript's text item delimiters
1679
+ set AppleScript's text item delimiters to linefeed
1680
+ set joined to labels as text
1681
+ set AppleScript's text item delimiters to saved
1682
+ return "__CONDUCTOR_DEFAULT_MODEL__" & tab & defaultLabel & linefeed & joined
1683
+ on error errText number errNum
1684
+ tell application "System Events" to key code 53
1685
+ error errText number errNum
1686
+ end try
1528
1687
  end listModels
1529
1688
 
1530
1689
  on applyAgentOptions()
@@ -31,7 +31,7 @@ import { HIT_CLOSE, HIT_OPEN, isToolResult, timestampMs, workspaceTitle } from "
31
31
  /** Versions we know how to speak. The client's choice wins when we know it. */
32
32
  export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
33
33
  export const SERVER_INFO = { name: 'conductor-remote', version: '1' };
34
- export const INSTRUCTIONS = 'Drives local Conductor agents through the conductor-remote relay. search_chats and read_chat reach archived workspaces, which is where most finished work lives. create_workspace opens its workspace link without direct UI control; requested agent settings and the first prompt are applied later through Conductor’s UI. dismiss_prompt, keep_awake and relay_logs touch no UI. send_prompt, split_chat, stop_turn, set_agent_options, a live list_models call, set_workspace_status and archive_workspace drive the real Mac UI and steal focus for a few seconds — confirm with the user before using them on a chat they did not name. archive_workspace also deletes the worktree and stops whatever is running there.';
34
+ export const INSTRUCTIONS = 'Drives local Conductor agents through the conductor-remote relay. search_chats and read_chat reach archived workspaces, which is where most finished work lives. create_workspace opens its workspace link without direct UI control; requested agent settings and the first prompt are applied later through Conductor’s UI. dismiss_prompt, keep_awake and relay_logs touch no UI. send_prompt, split_chat, stop_turn, set_agent_options, set_default_model, a live list_models call, set_workspace_status and archive_workspace drive the real Mac UI and steal focus for a few seconds — confirm with the user before using them on a chat they did not name. archive_workspace also deletes the worktree and stops whatever is running there.';
35
35
  /** Reads are quick; a UI write is measured in tens of seconds (writes.ts ▸ SEND_ATTEMPT_MS). */
36
36
  export const READ_TIMEOUT_MS = 10_000;
37
37
  export const WRITE_TIMEOUT_MS = 75_000;
@@ -579,14 +579,45 @@ export function createTools(call) {
579
579
  if (!data.groups.length)
580
580
  return 'no models are cached yet — read a chat’s live picker with session_id and workspace_id';
581
581
  return data.groups
582
- .map(group => `## ${group.agentType}\n${group.models.map(model => `- ${model}`).join('\n')}`)
582
+ .map(group => `## ${group.agentType}\n${group.models.map(model => `- ${model}${model === data.defaultModel ? ' ★ default' : ''}`).join('\n')}`)
583
583
  .join('\n\n');
584
584
  }
585
585
  const workspaceId = need(args, 'workspace_id');
586
586
  const data = await call(`${routes.models.path(sessionId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { timeoutMs: WRITE_TIMEOUT_MS });
587
587
  if (!data.ok)
588
588
  throw new Error(data.error ?? 'could not read the model menu');
589
- return data.models?.length ? data.models.join('\n') : 'the menu listed no models';
589
+ return data.models?.length
590
+ ? data.models.map(model => `${model}${model === data.defaultModel ? ' ★ default' : ''}`).join('\n')
591
+ : 'the menu listed no models';
592
+ }
593
+ },
594
+ {
595
+ name: 'set_default_model',
596
+ description: 'Star a model as Conductor’s user-wide default. This mirrors the desktop picker’s combined “set as default and select” action, so it also switches the target chat to that model for its next turn. DRIVES THE REAL UI and changes a global preference — ask the user first.',
597
+ inputSchema: {
598
+ type: 'object',
599
+ properties: {
600
+ session_id: { type: 'string', description: 'Chat whose live model picker should be used.' },
601
+ workspace_id: {
602
+ type: 'string',
603
+ description: 'Required in practice — the relay asserts the pane against it before pressing.'
604
+ },
605
+ model: { type: 'string', description: 'A label from list_models. An unambiguous prefix is enough.' }
606
+ },
607
+ required: ['session_id', 'workspace_id', 'model']
608
+ },
609
+ run: async (args) => {
610
+ const sessionId = need(args, 'session_id');
611
+ const workspaceId = need(args, 'workspace_id');
612
+ const model = need(args, 'model');
613
+ const data = await call(routes.defaultModel.path(sessionId), {
614
+ method: routes.defaultModel.method,
615
+ body: { workspaceId, model },
616
+ timeoutMs: WRITE_TIMEOUT_MS
617
+ });
618
+ if (!data.ok)
619
+ throw new Error(data.error ?? 'the default model did not change');
620
+ return `${data.defaultModel ?? model} is now the default and selected for this chat`;
590
621
  }
591
622
  },
592
623
  {
@@ -31,20 +31,28 @@ export class ModelCache {
31
31
  list() {
32
32
  return this.entries.map(entry => ({ ...entry, models: [...entry.models] }));
33
33
  }
34
+ /** The newest live picker read wins when upgrading from caches that disagree. */
35
+ defaultModel() {
36
+ return [...this.entries].sort((a, b) => b.updatedAt - a.updatedAt).find(entry => entry.defaultModel)?.defaultModel;
37
+ }
34
38
  /** Record the menu that was read from one chat. An empty or failed read changes nothing. */
35
- remember(agentType, models) {
39
+ remember(agentType, models, defaultModel) {
36
40
  const next = clean(models);
37
41
  if (!next.length)
38
42
  return;
39
43
  const key = group(agentType ?? '');
44
+ const selectedDefault = clean(defaultModel ? [defaultModel] : [])[0] ?? this.defaultModel();
40
45
  const entry = {
41
46
  agentType: key,
42
47
  // A live menu is authoritative. Replacing its group drops a model Conductor
43
48
  // stopped offering instead of leaving a stale choice in a later workspace.
44
49
  models: next,
50
+ defaultModel: selectedDefault,
45
51
  updatedAt: Date.now()
46
52
  };
47
- this.entries = [...this.entries.filter(existing => existing.agentType !== key), entry].sort((a, b) => a.agentType.localeCompare(b.agentType));
53
+ this.entries = [...this.entries.filter(existing => existing.agentType !== key), entry]
54
+ .map(existing => ({ ...existing, defaultModel: selectedDefault ?? existing.defaultModel }))
55
+ .sort((a, b) => a.agentType.localeCompare(b.agentType));
48
56
  this.save();
49
57
  }
50
58
  /** A model that Conductor accepted is also a useful choice for a later workspace. */
@@ -56,11 +64,20 @@ export class ModelCache {
56
64
  const entry = {
57
65
  agentType: key,
58
66
  models: clean([...(current?.models ?? []), model]),
67
+ defaultModel: current?.defaultModel ?? this.defaultModel(),
59
68
  updatedAt: Date.now()
60
69
  };
61
70
  this.entries = [...this.entries.filter(existing => existing.agentType !== key), entry].sort((a, b) => a.agentType.localeCompare(b.agentType));
62
71
  this.save();
63
72
  }
73
+ /** A successful star press is authoritative for every harness's copy of the same picker. */
74
+ rememberDefault(model) {
75
+ const selected = clean([model])[0];
76
+ if (!selected)
77
+ return;
78
+ this.entries = this.entries.map(entry => ({ ...entry, defaultModel: selected }));
79
+ this.save();
80
+ }
64
81
  load() {
65
82
  try {
66
83
  const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
@@ -71,6 +88,7 @@ export class ModelCache {
71
88
  .map(entry => ({
72
89
  agentType: group(entry.agentType),
73
90
  models: clean(entry.models.filter((model) => typeof model === 'string')),
91
+ defaultModel: typeof entry.defaultModel === 'string' ? clean([entry.defaultModel])[0] || undefined : undefined,
74
92
  updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : 0
75
93
  }))
76
94
  .filter(entry => entry.models.length);
@@ -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. */
@@ -96,6 +102,8 @@ export const routes = {
96
102
  // ── chats ──
97
103
  messages: param('GET', '/api/sessions/:sessionId/messages'),
98
104
  models: param('GET', '/api/sessions/:sessionId/models'),
105
+ /** Star a model as the user's default; Conductor also selects it for this chat. */
106
+ defaultModel: param('POST', '/api/sessions/:sessionId/default-model'),
99
107
  agent: param('POST', '/api/sessions/:sessionId/agent'),
100
108
  stop: param('POST', '/api/sessions/:sessionId/stop'),
101
109
  sendPrompt: param('POST', '/api/sessions/:sessionId/prompt'),
@@ -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, 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();
@@ -870,7 +870,7 @@ const server = http.createServer(async (req, res) => {
870
870
  // GET /api/models — prior live picker reads, without activating Conductor. A
871
871
  // new workspace has no chat yet, so this is its only safe source of choices.
872
872
  if (isRoute(routes.modelCatalog, req.method, pathname)) {
873
- return json(req, res, 200, { groups: modelCache.list() });
873
+ return json(req, res, 200, { groups: modelCache.list(), defaultModel: modelCache.defaultModel() });
874
874
  }
875
875
  // GET /api/settings — relay preferences plus what the phone needs to edit them:
876
876
  // the SSIDs this Mac already holds credentials for, so the picker offers a choice
@@ -957,6 +957,36 @@ const server = http.createServer(async (req, res) => {
957
957
  const result = await disarmNoSleep();
958
958
  return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
959
959
  }
960
+ // POST /api/conductor/restart { stopAgents? } — quit Conductor and start it again.
961
+ //
962
+ // The lever exists in the actuator already, but only as activateConductor's last
963
+ // resort, which fires exclusively for a *windowless* Conductor. This is for the
964
+ // other shape: window up, prompts landing as rows, and no agent output behind any
965
+ // of it (measured 2026-09-02 — 2h35m of user rows after the last agent frame).
966
+ // The running agents are counted from the DB before the UI is touched and refused
967
+ // unless the caller meant it, the same way archiving is: quitting ends every turn
968
+ // in flight. The lock screen is the actuator's own gate, since only it can ask.
969
+ if (isRoute(routes.restartConductor, req.method, pathname)) {
970
+ const body = JSON.parse((await readBody(req)) || '{}');
971
+ const working = reads.listSessionStates().filter(state => state.status === 'working').length;
972
+ if (working > 0 && body.stopAgents !== true) {
973
+ return json(req, res, 409, {
974
+ ok: false,
975
+ agentsRunning: true,
976
+ working,
977
+ error: `${working} chat${working === 1 ? ' is' : 's are'} mid-turn. Restarting Conductor ends ${working === 1 ? 'it' : 'them'}.`
978
+ });
979
+ }
980
+ const startedAt = Date.now();
981
+ const result = await restartConductorApp();
982
+ const ms = Date.now() - startedAt;
983
+ if (!result.ok) {
984
+ console.warn(`[restart] Conductor restart failed after ${(ms / 1000).toFixed(1)}s: ${result.error}`);
985
+ return json(req, res, 502, { ok: false, ms, error: result.error });
986
+ }
987
+ console.log(`[restart] quit Conductor and relaunched it in ${(ms / 1000).toFixed(1)}s`);
988
+ return json(req, res, 200, { ok: true, ms });
989
+ }
960
990
  // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
961
991
  // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
962
992
  // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
@@ -1362,9 +1392,37 @@ const server = http.createServer(async (req, res) => {
1362
1392
  return json(req, res, 409, { error: located.error });
1363
1393
  const result = await listAgentModels({ workspace: ws, sessionId, tab: located.tab });
1364
1394
  if (result.ok && result.models)
1365
- modelCache.remember(located.session?.agent_type, result.models);
1395
+ modelCache.remember(located.session?.agent_type, result.models, result.defaultModel);
1366
1396
  return json(req, res, result.ok ? 200 : 502, result);
1367
1397
  }
1398
+ // POST /api/sessions/:id/default-model { model, workspaceId? }
1399
+ // The picker star is a combined "set default and select" action, so this
1400
+ // changes both the user-wide default and this chat's model exactly as the
1401
+ // desktop control does.
1402
+ const defaultModelOf = routeParam(routes.defaultModel, req.method, pathname);
1403
+ if (defaultModelOf) {
1404
+ const sessionId = defaultModelOf;
1405
+ const body = JSON.parse((await readBody(req)) || '{}');
1406
+ if (typeof body.model !== 'string' || !body.model.trim()) {
1407
+ return json(req, res, 400, { error: 'model must be a picker label' });
1408
+ }
1409
+ const ws = typeof body.workspaceId === 'string'
1410
+ ? reads.getWorkspace(body.workspaceId)
1411
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
1412
+ if (!ws)
1413
+ return json(req, res, 404, { error: 'workspace for session not found' });
1414
+ const located = locateChat(ws, sessionId);
1415
+ if ('error' in located)
1416
+ return json(req, res, 409, { error: located.error });
1417
+ const result = await setDefaultModel({ workspace: ws, sessionId, tab: located.tab }, body.model.trim());
1418
+ if (!result.ok || !result.model) {
1419
+ return json(req, res, 502, { ok: false, error: result.error ?? 'the default model did not change' });
1420
+ }
1421
+ const session = reads.listSessions(ws.id).find(row => row.id === sessionId);
1422
+ modelCache.rememberModel(session?.agent_type, result.model);
1423
+ modelCache.rememberDefault(result.model);
1424
+ return json(req, res, 200, { ok: true, defaultModel: result.model, session });
1425
+ }
1368
1426
  // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
1369
1427
  // Drives the composer's own model/effort/plan/fast controls for one chat.
1370
1428
  const agentOf = routeParam(routes.agent, req.method, pathname);
@@ -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.
@@ -578,6 +585,36 @@ return "ok"`.trim();
578
585
  return { ok: false, strategy: 'applescript', error: osaError(err) };
579
586
  }
580
587
  }
588
+ /**
589
+ * Star one picker row as Conductor's user-wide default model.
590
+ *
591
+ * The desktop exposes this as a child button named "Set … as default and select",
592
+ * so this deliberately has both effects: it changes the global default and selects
593
+ * that model for `target`. The AppleScript reopens the picker and reads the unique
594
+ * starred row back before this reports success.
595
+ */
596
+ export async function setDefaultModel(target, model) {
597
+ const script = `
598
+ ${CONDUCTOR_HANDLERS}
599
+
600
+ my activateConductor()
601
+ my focusWorkspace()
602
+ my selectChatTab()
603
+ return my setDefaultModel(system attribute "RELAY_DEFAULT_MODEL")`.trim();
604
+ try {
605
+ const { stdout } = await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
606
+ env: { ...process.env, ...targetEnvironment, RELAY_DEFAULT_MODEL: model },
607
+ timeout: SEND_ATTEMPT_MS
608
+ })));
609
+ const selected = modelPickerLabel(stdout.trim());
610
+ if (!selected)
611
+ return { ok: false, strategy: 'applescript', error: 'Conductor returned no default model' };
612
+ return { ok: true, strategy: 'applescript', model: selected };
613
+ }
614
+ catch (err) {
615
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
616
+ }
617
+ }
581
618
  /**
582
619
  * Put a workspace away — Conductor's own "Archive workspace" (Command-Shift-A),
583
620
  * pressed on the workspace this run has just focused and asserted.
@@ -619,6 +656,42 @@ return "ok"`.trim();
619
656
  return { ok: false, strategy: 'applescript', error: osaError(err) };
620
657
  }
621
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
+ }
622
695
  /**
623
696
  * The workspace statuses Conductor's sidebar groups by, mapped from the value it
624
697
  * stores in `workspaces.manual_status` to the label on its own menu. `canceled`
@@ -674,7 +747,24 @@ return "ok"`.trim();
674
747
  return { ok: false, strategy: 'applescript', error: osaError(err) };
675
748
  }
676
749
  }
677
- /** The model labels Conductor is currently offering, read off the live picker. */
750
+ const DEFAULT_MODEL_LINE = '__CONDUCTOR_DEFAULT_MODEL__\t';
751
+ /** Turn listModels' tagged line protocol into the live picker state. Exported for its parser tests. */
752
+ export function parseModelMenuOutput(stdout) {
753
+ let defaultModel;
754
+ const models = [];
755
+ for (const raw of stdout.split('\n')) {
756
+ const line = raw.trim();
757
+ if (!line)
758
+ continue;
759
+ if (line.startsWith(DEFAULT_MODEL_LINE)) {
760
+ defaultModel = modelPickerLabel(line.slice(DEFAULT_MODEL_LINE.length).trim()) || undefined;
761
+ continue;
762
+ }
763
+ models.push(modelPickerLabel(line));
764
+ }
765
+ return { models: [...new Set(models.filter(Boolean))], defaultModel };
766
+ }
767
+ /** The model labels Conductor is currently offering, plus its starred default. */
678
768
  export async function listAgentModels(target) {
679
769
  const script = `
680
770
  ${CONDUCTOR_HANDLERS}
@@ -688,12 +778,7 @@ return my listModels()`.trim();
688
778
  env: { ...process.env, ...targetEnvironment },
689
779
  timeout: SEND_ATTEMPT_MS
690
780
  })));
691
- const models = stdout
692
- .split('\n')
693
- .map(s => s.trim())
694
- .map(modelPickerLabel)
695
- .filter(Boolean);
696
- return { ok: true, models: [...new Set(models)] };
781
+ return { ok: true, ...parseModelMenuOutput(stdout) };
697
782
  }
698
783
  catch (err) {
699
784
  return { ok: false, error: osaError(err) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.78.1",
3
+ "version": "1.80.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.",