conductor-remote 1.25.0 → 1.26.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-qjWM9xpd.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-D6B0k-tm.css">
16
+ <script type="module" crossorigin src="/assets/index-q0jubVD3.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-0lGqzMHX.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,i={};const s=(s,n)=>(s=new URL(s+".js",n).href,i[s]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=i,document.head.appendChild(e)}else e=s,importScripts(s),i()}).then(()=>{let e=i[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const t=e=>s(e,o),c={module:{uri:o},exports:l,require:t};i[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"544bc260095fe9ec2113480b6e679eee"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-qjWM9xpd.js",revision:null},{url:"assets/index-D6B0k-tm.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{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,i={};const s=(s,n)=>(s=new URL(s+".js",n).href,i[s]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=i,document.head.appendChild(e)}else e=s,importScripts(s),i()}).then(()=>{let e=i[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const c=e=>s(e,o),t={module:{uri:o},exports:l,require:c};i[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"a8e25a01d32edf68c3ca0b827fb79612"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-q0jubVD3.js",revision:null},{url:"assets/index-0lGqzMHX.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -74,6 +74,23 @@ export class Reads {
74
74
  getWorkspace(id) {
75
75
  return this.listWorkspaces().find(w => w.id === id) ?? null;
76
76
  }
77
+ /**
78
+ * The repos Conductor knows about, in its own sidebar order. `root_path` is
79
+ * what a `conductor://…&path=` deep link needs to pick the target repo — with
80
+ * no path Conductor silently falls back to the *first* repo.
81
+ */
82
+ listRepos() {
83
+ const rows = this.db.query(`SELECT name, root_path, default_branch, icon, remote_url
84
+ FROM repos
85
+ WHERE COALESCE(hidden, 0) = 0
86
+ ORDER BY (display_order IS NULL), display_order, name`);
87
+ return rows.map(r => ({
88
+ name: r.name,
89
+ root_path: r.root_path,
90
+ default_branch: r.default_branch,
91
+ icon: describeRepoIcon({ icon: r.icon, repoRoot: r.root_path, remoteUrl: r.remote_url })
92
+ }));
93
+ }
77
94
  /** Resolve a repo's icon by its name (the sidebar avatar) — null if the repo or icon is unknown. */
78
95
  resolveRepoIcon(repoName) {
79
96
  const rows = this.db.query('SELECT root_path FROM repos WHERE name = ? LIMIT 1', [
@@ -84,7 +101,8 @@ export class Reads {
84
101
  }
85
102
  listSessions(workspaceId) {
86
103
  // created_at ASC keeps tab order stable (matches the desktop app) instead of jumping on activity.
87
- return this.db.query(`SELECT id, status, title, model, permission_mode, context_used_percent, unread_count,
104
+ return this.db.query(`SELECT id, status, title, model, permission_mode, claude_effort_level, fast_mode, agent_type,
105
+ context_used_percent, unread_count,
88
106
  created_at, updated_at, last_user_message_at
89
107
  FROM sessions
90
108
  WHERE workspace_id = ? AND COALESCE(is_hidden, 0) = 0
@@ -12,7 +12,7 @@ import { mergePr } from "./merge.js";
12
12
  import { attachPrStatus } from "./pr.js";
13
13
  import { Reads } from "./reads.js";
14
14
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
15
- import { describeActuator, newChat, pickActuator } from "./writes.js";
15
+ import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, newChat, pickActuator, setAgentOptions } from "./writes.js";
16
16
  const cfg = loadConfig();
17
17
  const db = new ConductorDb(cfg.dbPath);
18
18
  const reads = new Reads(db, cfg.workspacesRoot);
@@ -37,6 +37,65 @@ async function confirmDelivery(sessionId, text, sinceRowid) {
37
37
  }
38
38
  return false;
39
39
  }
40
+ /**
41
+ * Where a chat sits in Conductor's tab strip. Both write paths need it: the
42
+ * actuator selects that tab before touching anything, otherwise it acts on
43
+ * whichever tab happens to be active.
44
+ */
45
+ function locateChat(ws, sessionId) {
46
+ const sessions = reads.listSessions(ws.id);
47
+ const index = sessions.findIndex(s => s.id === sessionId);
48
+ if (index < 0 && sessions.length > 1)
49
+ return { error: 'chat is no longer one of the workspace’s tabs' };
50
+ if (index < 0)
51
+ return { tab: undefined, session: undefined };
52
+ return {
53
+ tab: { index: index + 1, count: sessions.length, title: sessions[index].title ?? '' },
54
+ session: sessions[index]
55
+ };
56
+ }
57
+ /** Poll the DB until Conductor records the setting we just drove through the UI. */
58
+ async function confirmAgentOptions(ws, sessionId, opts) {
59
+ for (let attempt = 0; attempt < 10; attempt++) {
60
+ const s = reads.listSessions(ws.id).find(row => row.id === sessionId);
61
+ const effortOk = !opts.effort || s?.claude_effort_level === opts.effort;
62
+ const planOk = opts.plan === undefined || s?.permission_mode === (opts.plan ? 'plan' : 'default');
63
+ if (effortOk && planOk)
64
+ return true;
65
+ await sleep(300);
66
+ }
67
+ return false;
68
+ }
69
+ /**
70
+ * Send a freshly-created workspace's first prompt. Never fails the request: the
71
+ * workspace exists either way, so an unsent prompt is a `warning` (it's still
72
+ * pre-filled in Conductor's composer, one tap from going) rather than an error.
73
+ */
74
+ async function submitFirstPrompt(workspaceId, prompt) {
75
+ // A new workspace is 'setting_up' while its worktree (and setup script) runs;
76
+ // its composer isn't the visible pane yet, so wait for 'ready' before typing.
77
+ let ws = reads.getWorkspace(workspaceId);
78
+ for (let attempt = 0; attempt < 60 && ws?.state !== 'ready'; attempt++) {
79
+ await sleep(500);
80
+ ws = reads.getWorkspace(workspaceId);
81
+ }
82
+ if (ws?.state !== 'ready') {
83
+ return { sent: false, warning: 'Workspace created; still setting up, so the prompt is pre-filled but not sent.' };
84
+ }
85
+ const located = locateChat(ws, reads.listSessions(workspaceId)[0]?.id ?? '');
86
+ const tab = 'error' in located ? undefined : located.tab;
87
+ const sessionId = reads.listSessions(workspaceId)[0]?.id;
88
+ if (!sessionId)
89
+ return { sent: false, warning: 'Workspace created, but it has no chat yet — prompt is pre-filled.' };
90
+ const beforeRowid = reads.getMessages(sessionId).cursor;
91
+ const result = await actuator.send({ workspace: ws, sessionId, tab }, prompt);
92
+ if (!result.ok)
93
+ return { sent: false, warning: `Workspace created; the prompt is pre-filled but wasn’t sent (${result.error}).` };
94
+ if (!(await confirmDelivery(sessionId, prompt, beforeRowid))) {
95
+ return { sent: false, warning: 'Workspace created; the prompt is pre-filled but didn’t land — send it again.' };
96
+ }
97
+ return { sent: true };
98
+ }
40
99
  const MIME = {
41
100
  '.html': 'text/html; charset=utf-8',
42
101
  '.js': 'text/javascript; charset=utf-8',
@@ -150,6 +209,57 @@ const server = http.createServer(async (req, res) => {
150
209
  update
151
210
  });
152
211
  }
212
+ // GET /api/repos — repos a new workspace can be created in
213
+ if (req.method === 'GET' && pathname === '/api/repos') {
214
+ return json(req, res, 200, { repos: reads.listRepos() });
215
+ }
216
+ // POST /api/workspaces { repo, prompt, send? } — create a workspace via Conductor's deep link
217
+ if (req.method === 'POST' && pathname === '/api/workspaces') {
218
+ const body = JSON.parse((await readBody(req)) || '{}');
219
+ // The prompt is optional — a bare `path=` opens an empty workspace, like
220
+ // Conductor's own New workspace — but *something* has to say where it goes.
221
+ const prompt = (body.prompt ?? '').trim();
222
+ if (!prompt && !body.repo)
223
+ return json(req, res, 400, { error: 'need a repo or a prompt' });
224
+ // Resolve the repo to a real path: an unmatched `path` would silently land
225
+ // the workspace in whichever repo Conductor happens to list first.
226
+ const repo = body.repo ? reads.listRepos().find(r => r.name === body.repo) : undefined;
227
+ if (body.repo && !repo)
228
+ return json(req, res, 404, { error: `unknown repo ${body.repo}` });
229
+ if (repo && !repo.root_path)
230
+ return json(req, res, 409, { error: `${repo.name} has no checkout path` });
231
+ const before = new Set(reads.listWorkspaces().map(w => w.id));
232
+ const result = await createWorkspace(prompt, repo?.root_path ?? null);
233
+ if (!result.ok)
234
+ return json(req, res, 502, result);
235
+ // The deep link is fire-and-forget, so the new row is the only proof it worked.
236
+ // Creating a worktree takes a beat longer than opening a chat does.
237
+ let created;
238
+ for (let attempt = 0; attempt < 40 && !created; attempt++) {
239
+ await sleep(500);
240
+ created = reads.listWorkspaces().find(w => !before.has(w.id));
241
+ }
242
+ if (!created) {
243
+ return json(req, res, 502, {
244
+ ok: false,
245
+ strategy: result.strategy,
246
+ error: 'Conductor didn’t create a workspace — check it’s running and not showing a dialog.'
247
+ });
248
+ }
249
+ // Return as soon as the row exists (~2s) and let the caller submit the prompt
250
+ // once the worktree is ready. Waiting here would block the request through
251
+ // Conductor's whole setup — measured at 30s+ on a real repo, past the phone's
252
+ // own 25s budget. `send:true` opts into the blocking path for API callers.
253
+ // Whatever happens, the prompt is already pre-filled in Conductor's composer.
254
+ const submitted = body.send === true && prompt ? await submitFirstPrompt(created.id, prompt) : { sent: false };
255
+ return json(req, res, 200, {
256
+ ok: true,
257
+ workspaceId: created.id,
258
+ workspace: reads.getWorkspace(created.id) ?? created,
259
+ pendingPrompt: prompt || undefined,
260
+ ...submitted
261
+ });
262
+ }
153
263
  // GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/icons.ts)
154
264
  let m = pathname.match(/^\/api\/repos\/([^/]+)\/icon$/);
155
265
  if (req.method === 'GET' && m) {
@@ -213,6 +323,56 @@ const server = http.createServer(async (req, res) => {
213
323
  const after = Number(url.searchParams.get('after') ?? 0);
214
324
  return json(req, res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
215
325
  }
326
+ // GET /api/sessions/:id/models?workspaceId= — labels from Conductor's live picker
327
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/models$/);
328
+ if (req.method === 'GET' && m) {
329
+ const sessionId = decodeURIComponent(m[1]);
330
+ const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
331
+ if (!ws)
332
+ return json(req, res, 404, { error: 'workspace for session not found' });
333
+ const located = locateChat(ws, sessionId);
334
+ if ('error' in located)
335
+ return json(req, res, 409, { error: located.error });
336
+ const result = await listAgentModels({ workspace: ws, sessionId, tab: located.tab });
337
+ return json(req, res, result.ok ? 200 : 502, result);
338
+ }
339
+ // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
340
+ // Drives the composer's own model/effort/plan/fast controls for one chat.
341
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/agent$/);
342
+ if (req.method === 'POST' && m) {
343
+ const sessionId = decodeURIComponent(m[1]);
344
+ const body = JSON.parse((await readBody(req)) || '{}');
345
+ if (body.effort && !EFFORT_LABELS[body.effort]) {
346
+ return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
347
+ }
348
+ const ws = body.workspaceId
349
+ ? reads.getWorkspace(body.workspaceId)
350
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
351
+ if (!ws)
352
+ return json(req, res, 404, { error: 'workspace for session not found' });
353
+ const located = locateChat(ws, sessionId);
354
+ if ('error' in located)
355
+ return json(req, res, 409, { error: located.error });
356
+ // Fast mode exposes no readable state in the UI, so the DB decides whether
357
+ // the button actually needs pressing — pressing blindly would toggle it off.
358
+ const opts = {
359
+ effort: body.effort,
360
+ plan: body.plan,
361
+ model: body.model,
362
+ toggleFast: body.fast === undefined ? false : body.fast !== Boolean(located.session?.fast_mode)
363
+ };
364
+ const result = await setAgentOptions({ workspace: ws, sessionId, tab: located.tab }, opts);
365
+ if (!result.ok)
366
+ return json(req, res, 502, result);
367
+ if (!(await confirmAgentOptions(ws, sessionId, opts))) {
368
+ return json(req, res, 502, {
369
+ ok: false,
370
+ strategy: result.strategy,
371
+ error: 'Conductor didn’t record the change — it may have been asleep. Try again.'
372
+ });
373
+ }
374
+ return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
375
+ }
216
376
  // POST /api/sessions/:id/prompt { text }
217
377
  m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
218
378
  if (req.method === 'POST' && m) {
@@ -226,9 +386,13 @@ const server = http.createServer(async (req, res) => {
226
386
  : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
227
387
  if (!ws)
228
388
  return json(req, res, 404, { error: 'workspace for session not found' });
389
+ const located = locateChat(ws, sessionId);
390
+ if ('error' in located)
391
+ return json(req, res, 409, { error: located.error });
392
+ const { tab } = located;
229
393
  // Snapshot the transcript cursor so we can confirm the prompt actually lands.
230
394
  const beforeRowid = reads.getMessages(sessionId).cursor;
231
- const result = await actuator.send({ workspace: ws, sessionId }, text);
395
+ const result = await actuator.send({ workspace: ws, sessionId, tab }, text);
232
396
  if (result.ok && !(await confirmDelivery(sessionId, text, beforeRowid))) {
233
397
  return json(req, res, 502, {
234
398
  ok: false,