conductor-remote 1.88.0 → 1.89.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
@@ -25,8 +25,8 @@
25
25
  <title>Conductor Remote</title>
26
26
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
27
27
  <script src="/self-heal.js"></script>
28
- <script type="module" crossorigin src="/assets/index-DniQHCcH.js"></script>
29
- <link rel="stylesheet" crossorigin href="/assets/index-CDKjnPTR.css">
28
+ <script type="module" crossorigin src="/assets/index-E7UanAoE.js"></script>
29
+ <link rel="stylesheet" crossorigin href="/assets/index-Bo6T0C__.css">
30
30
  <link rel="manifest" href="/manifest.webmanifest"></head>
31
31
  <body>
32
32
  <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:"ef4c1a302a119c18934e834f5487bec5"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DniQHCcH.js",revision:null},{url:"assets/index-CDKjnPTR.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 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:"a0eac09ea3fd8b6248c5f20a6be9d3cb"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-E7UanAoE.js",revision:null},{url:"assets/index-Bo6T0C__.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\//]}))});
@@ -1272,6 +1272,44 @@ on cancelAgent()
1272
1272
  delay 0.3
1273
1273
  end cancelAgent
1274
1274
 
1275
+ on closeChatTab()
1276
+ -- Hide the selected chat through Conductor's own Close tab shortcut, Command-W.
1277
+ -- The same chord closes a terminal tab when the terminal has focus, so Command-L
1278
+ -- first is load-bearing: it moves focus into the chat whose workspace and tab were
1279
+ -- asserted immediately above this handler.
1280
+ set strips to my tabGroups()
1281
+ if (count of strips) is 0 then error "couldn't find the chat pane to close from"
1282
+ my assertWorkspace(item 1 of strips)
1283
+ tell application "System Events"
1284
+ keystroke "l" using {command down}
1285
+ delay 0.2
1286
+ keystroke "w" using {command down}
1287
+ end tell
1288
+
1289
+ -- Conductor asks "Close anyway" when the chat's agent is still running. The
1290
+ -- server normally catches that from sessions.status before touching the UI, but
1291
+ -- a turn can start in the gap between that read and this keystroke. Detect the
1292
+ -- live dialog too, and never infer permission to hide a running conversation.
1293
+ set confirmEl to missing value
1294
+ set checks to 1
1295
+ if (system attribute "RELAY_CLOSE_RUNNING") is "1" then set checks to 4
1296
+ repeat with attempt from 1 to checks
1297
+ delay 0.25
1298
+ set confirmEl to my closeConfirmButton()
1299
+ if confirmEl is not missing value then exit repeat
1300
+ end repeat
1301
+ if confirmEl is missing value then return "closed"
1302
+ if (system attribute "RELAY_CLOSE_RUNNING") is not "1" then
1303
+ tell application "System Events" to key code 53
1304
+ error "the agent started working before the tab closed - closing it now needs confirmation"
1305
+ end if
1306
+ tell application "System Events" to tell process "Conductor"
1307
+ perform action "AXPress" of confirmEl
1308
+ end tell
1309
+ delay 0.5
1310
+ return "closed"
1311
+ end closeChatTab
1312
+
1275
1313
  on buttonsUnder(root, maxDepth)
1276
1314
  -- Every AXButton at or below root, level-order and bounded. Depth is the whole
1277
1315
  -- cost here: the transcript hangs off the same web area as anything Conductor
@@ -1298,20 +1336,35 @@ on buttonsUnder(root, maxDepth)
1298
1336
  return acc
1299
1337
  end buttonsUnder
1300
1338
 
1301
- on archiveButtons()
1302
- -- Both places Conductor could be drawing its archive confirmation, each at the
1303
- -- depth that shape actually sits at. A real sheet hangs off the window a level or
1304
- -- two down; an in-webview dialog is a portal near the top of the web area, which
1305
- -- is where the sidebar row's menu turns up at depth 4 (pressStatusMenu). Sweeping
1306
- -- one deep cap from the window instead would reach neither any better and would
1307
- -- walk the transcript to get there.
1339
+ on dialogButtons()
1340
+ -- Both places Conductor could be drawing a confirmation, each at the depth that
1341
+ -- shape actually sits at. A real sheet hangs off the window a level or two down;
1342
+ -- an in-webview dialog is a shallow portal near the top of the web area. Its
1343
+ -- buttons are inside depth 3; going to 4 reaches the transcript and made an idle
1344
+ -- close spend its whole 28-second budget proving that no dialog existed.
1308
1345
  my requireWindow()
1309
1346
  set fromWindow to {}
1310
1347
  tell application "System Events" to tell process "Conductor"
1311
1348
  set fromWindow to my buttonsUnder(window 1, 4)
1312
1349
  end tell
1313
- return fromWindow & (my buttonsUnder(my webArea(), 4))
1314
- end archiveButtons
1350
+ return fromWindow & (my buttonsUnder(my webArea(), 3))
1351
+ end dialogButtons
1352
+
1353
+ on closeConfirmButton()
1354
+ -- The running-chat dialog has exactly Cancel + "Close anyway". Require the
1355
+ -- pair so an unrelated destructive dialog can never donate a similarly named
1356
+ -- button to this run.
1357
+ set found to missing value
1358
+ set sawCancel to false
1359
+ repeat with entry in my dialogButtons()
1360
+ set node to contents of entry
1361
+ set label to my axName(node)
1362
+ if label is "Cancel" then set sawCancel to true
1363
+ if label starts with "Close anyway" then set found to node
1364
+ end repeat
1365
+ if sawCancel then return found
1366
+ return missing value
1367
+ end closeConfirmButton
1315
1368
 
1316
1369
  on archiveConfirmButton()
1317
1370
  -- Conductor asks before archiving a workspace whose agents are still running:
@@ -1323,7 +1376,7 @@ on archiveConfirmButton()
1323
1376
  -- and would otherwise read that as a confirmation of this.
1324
1377
  set found to missing value
1325
1378
  set sawCancel to false
1326
- repeat with entry in my archiveButtons()
1379
+ repeat with entry in my dialogButtons()
1327
1380
  set node to contents of entry
1328
1381
  set label to my axName(node)
1329
1382
  if label is "Cancel" then set sawCancel to true
@@ -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. plan_usage, 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.';
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. plan_usage, dismiss_prompt, keep_awake and relay_logs touch no UI. send_prompt, split_chat, stop_turn, close_chat, 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;
@@ -577,6 +577,41 @@ export function createTools(call) {
577
577
  return 'stopped';
578
578
  }
579
579
  },
580
+ {
581
+ name: 'close_chat',
582
+ description: 'Close one visible Conductor chat tab. This hides the tab but keeps its transcript; it can be reopened in Conductor with ⌘⇧T. DRIVES THE REAL UI and steals focus for a few seconds. A running chat is refused unless close_running is true, matching Conductor’s own “Close anyway” confirmation — only set it after the user confirms.',
583
+ inputSchema: {
584
+ type: 'object',
585
+ properties: {
586
+ session_id: { type: 'string', description: 'The visible chat tab to close.' },
587
+ workspace_id: {
588
+ type: 'string',
589
+ description: 'Workspace holding the chat. The relay verifies it when provided.'
590
+ },
591
+ close_running: {
592
+ type: 'boolean',
593
+ description: 'Close even though its agent is still working. Requires explicit user confirmation.'
594
+ }
595
+ },
596
+ required: ['session_id']
597
+ },
598
+ run: async (args) => {
599
+ const sessionId = need(args, 'session_id');
600
+ const data = await call(routes.closeChat.path(sessionId), {
601
+ method: routes.closeChat.method,
602
+ body: {
603
+ workspaceId: str(args.workspace_id),
604
+ closeRunning: args.close_running === true
605
+ },
606
+ timeoutMs: WRITE_TIMEOUT_MS
607
+ });
608
+ if (!data.ok)
609
+ throw new Error(data.error ?? 'the chat tab did not close');
610
+ if (data.alreadyClosed)
611
+ return 'already closed';
612
+ return data.activeSessionId ? `closed; active session_id: ${data.activeSessionId}` : 'closed; no tabs remain';
613
+ }
614
+ },
580
615
  {
581
616
  name: 'list_models',
582
617
  description: 'The model labels the relay knows. With no session_id this returns its stored picker labels, grouped by harness; use it before create_workspace. With a session_id it reads that chat’s live Conductor picker and refreshes the stored labels. A live read DRIVES THE REAL UI — it focuses the workspace and opens the menu — so it costs a few seconds of stolen focus.',
@@ -112,6 +112,8 @@ export const routes = {
112
112
  defaultModel: param('POST', '/api/sessions/:sessionId/default-model'),
113
113
  agent: param('POST', '/api/sessions/:sessionId/agent'),
114
114
  stop: param('POST', '/api/sessions/:sessionId/stop'),
115
+ /** Hide one chat tab through Conductor's own Close tab action (Command-W). */
116
+ closeChat: param('DELETE', '/api/sessions/:sessionId'),
115
117
  sendPrompt: param('POST', '/api/sessions/:sessionId/prompt'),
116
118
  /** Write a phone-selected file into Conductor's attachment layout for this chat's workspace. */
117
119
  uploadAttachment: param('POST', '/api/sessions/:sessionId/attachments'),
@@ -35,7 +35,7 @@ import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttac
35
35
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
36
36
  import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
37
37
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
38
- import { archiveWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, planSettingForUi, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
38
+ import { archiveWorkspace, closeChat, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, planSettingForUi, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
39
39
  // Before anything that logs: from here on every console line is also kept in memory for
40
40
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
41
41
  installLogCapture();
@@ -1560,6 +1560,83 @@ const server = http.createServer(async (req, res) => {
1560
1560
  session: reads.listSessions(ws.id).find(s => s.id === sessionId)
1561
1561
  });
1562
1562
  }
1563
+ // DELETE /api/sessions/:id { workspaceId?, closeRunning? } — Conductor's
1564
+ // reversible Close tab action (Command-W). A running chat gets the same
1565
+ // explicit "Close anyway" gate as the desktop app.
1566
+ const closeChatId = routeParam(routes.closeChat, req.method, pathname);
1567
+ if (closeChatId) {
1568
+ const sessionId = closeChatId;
1569
+ const body = JSON.parse((await readBody(req)) || '{}');
1570
+ const ownerId = reads.sessionWorkspaceId(sessionId);
1571
+ if (!ownerId)
1572
+ return json(req, res, 404, { error: 'chat not found' });
1573
+ if (body.workspaceId && body.workspaceId !== ownerId) {
1574
+ return json(req, res, 409, { error: 'chat is not in that workspace' });
1575
+ }
1576
+ const ws = reads.getWorkspace(ownerId);
1577
+ if (!ws)
1578
+ return json(req, res, 404, { error: 'workspace for session not found' });
1579
+ const before = reads.listSessions(ws.id);
1580
+ const session = before.find(s => s.id === sessionId);
1581
+ const visibleActiveSession = () => {
1582
+ const visible = reads.listSessions(ws.id);
1583
+ const activeId = reads.getWorkspace(ws.id)?.active_session_id;
1584
+ return visible.some(s => s.id === activeId) ? (activeId ?? null) : (visible[0]?.id ?? null);
1585
+ };
1586
+ // Closing is a soft delete. A repeat whose first response was lost is already
1587
+ // in the requested state, so it is success rather than a stale-link error.
1588
+ if (!session) {
1589
+ return json(req, res, 200, {
1590
+ ok: true,
1591
+ alreadyClosed: true,
1592
+ activeSessionId: visibleActiveSession()
1593
+ });
1594
+ }
1595
+ if ((session.status === 'working' || session.background_tasks.length > 0) && body.closeRunning !== true) {
1596
+ return json(req, res, 409, {
1597
+ ok: false,
1598
+ agentRunning: true,
1599
+ error: 'The agent is still working in this chat. Confirm closing it anyway.'
1600
+ });
1601
+ }
1602
+ const located = locateChat(ws, sessionId);
1603
+ if ('error' in located)
1604
+ return json(req, res, 409, { error: located.error });
1605
+ const result = await closeChat({ workspace: ws, sessionId, tab: located.tab }, body.closeRunning === true);
1606
+ if (!result.ok) {
1607
+ // A turn can start after the status read above. The script dismisses
1608
+ // Conductor's surprise dialog instead of accepting it, and this sends the
1609
+ // caller back through the same explicit confirmation path.
1610
+ if (body.closeRunning !== true && result.error?.includes('needs confirmation')) {
1611
+ return json(req, res, 409, {
1612
+ ok: false,
1613
+ agentRunning: true,
1614
+ error: 'The agent is still working in this chat. Confirm closing it anyway.'
1615
+ });
1616
+ }
1617
+ return json(req, res, 502, result);
1618
+ }
1619
+ // Command-W is fire-and-forget. The durable receipt is the same flag all tab
1620
+ // reads filter on: this id disappearing from listSessions means Conductor set
1621
+ // sessions.is_hidden, not merely that a keystroke happened.
1622
+ let visible = true;
1623
+ for (let i = 0; i < 20 && visible; i++) {
1624
+ await sleep(300);
1625
+ visible = reads.listSessions(ws.id).some(s => s.id === sessionId);
1626
+ }
1627
+ if (visible) {
1628
+ return json(req, res, 502, {
1629
+ ok: false,
1630
+ strategy: result.strategy,
1631
+ error: 'Conductor took the close but the chat tab is still open. Try again, or close it on your Mac.'
1632
+ });
1633
+ }
1634
+ return json(req, res, 200, {
1635
+ ok: true,
1636
+ strategy: result.strategy,
1637
+ activeSessionId: visibleActiveSession()
1638
+ });
1639
+ }
1563
1640
  // POST /api/sessions/:id/attachments?workspaceId= — raw bytes from the phone.
1564
1641
  // Conductor derives an attachment from this on-disk layout plus the composer
1565
1642
  // token, so its own database stays read-only from this relay's point of view.
@@ -492,6 +492,45 @@ return "ok"`.trim();
492
492
  return { ok: false, strategy: 'applescript', error: osaError(err) };
493
493
  }
494
494
  }
495
+ /**
496
+ * Hide one chat through Conductor's own Close tab action (Command-W).
497
+ *
498
+ * Like `stopTurn`, the branch is required because Command-W acts on whatever has
499
+ * keyboard focus. `selectChatTab` proves the target and `closeChatTab` moves focus
500
+ * back out of the terminal before pressing it. Closing is reversible in Conductor,
501
+ * but a running chat still gets its desktop confirmation: `closeRunning` is the
502
+ * caller's explicit answer to that prompt, never something this layer infers.
503
+ *
504
+ * The script only reports that it pressed the action. `sessions.is_hidden` is the
505
+ * receipt; server.ts waits until `Reads.listSessions` no longer returns this id.
506
+ */
507
+ export async function closeChat(target, closeRunning) {
508
+ if (!target.workspace.branch) {
509
+ return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
510
+ }
511
+ const script = `
512
+ ${CONDUCTOR_HANDLERS}
513
+
514
+ my activateConductor()
515
+ my focusWorkspace()
516
+ my selectChatTab()
517
+ my closeChatTab()
518
+ return "ok"`.trim();
519
+ try {
520
+ await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
521
+ env: {
522
+ ...process.env,
523
+ ...targetEnvironment,
524
+ RELAY_CLOSE_RUNNING: closeRunning ? '1' : ''
525
+ },
526
+ timeout: SEND_ATTEMPT_MS
527
+ })));
528
+ return { ok: true, strategy: 'applescript' };
529
+ }
530
+ catch (err) {
531
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
532
+ }
533
+ }
495
534
  /**
496
535
  * Press the Wi-Fi menu's row for a personal hotspot — Instant Hotspot, the same
497
536
  * button a human clicks. `networksetup` can only join a network that is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.88.0",
3
+ "version": "1.89.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.",