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/README.md +2 -2
- package/dist/assets/index-DaafT1eA.css +1 -0
- package/dist/assets/index-ILQ9q-V7.js +47 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor.applescript +184 -25
- package/dist-node/src/mcp-tools.js +34 -3
- package/dist-node/src/model-cache.js +20 -2
- package/dist-node/src/routes.js +8 -0
- package/dist-node/src/server.js +61 -3
- package/dist-node/src/writes.js +92 -7
- package/package.json +1 -1
- package/dist/assets/index-BoeZwP2N.css +0 -1
- package/dist/assets/index-dOiHAzne.js +0 -47
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-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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.
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
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]
|
|
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);
|
package/dist-node/src/routes.js
CHANGED
|
@@ -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'),
|
package/dist-node/src/server.js
CHANGED
|
@@ -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);
|
package/dist-node/src/writes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.",
|