conductor-remote 1.48.0 → 1.49.1

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-CfrbqYvg.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-ajfm3IVV.css">
16
+ <script type="module" crossorigin src="/assets/index-qLOtnZJy.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-DWYStsWl.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 l={};const t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(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:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"631e5f34f85cfff6eb41b43307d96eb6"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-ajfm3IVV.css",revision:null},{url:"assets/index-CfrbqYvg.js",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,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 t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(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:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"08562b5f5dbf07b0c55881ab9c52d8b9"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-qLOtnZJy.js",revision:null},{url:"assets/index-DWYStsWl.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\//]}))});
@@ -0,0 +1,59 @@
1
+ const SOURCE_EXTENSIONS = new Set([
2
+ '.bash',
3
+ '.c',
4
+ '.cc',
5
+ '.cpp',
6
+ '.css',
7
+ '.go',
8
+ '.h',
9
+ '.hpp',
10
+ '.html',
11
+ '.java',
12
+ '.js',
13
+ '.json',
14
+ '.jsx',
15
+ '.md',
16
+ '.mjs',
17
+ '.mts',
18
+ '.php',
19
+ '.py',
20
+ '.rb',
21
+ '.rs',
22
+ '.scss',
23
+ '.sh',
24
+ '.sql',
25
+ '.svg',
26
+ '.swift',
27
+ '.toml',
28
+ '.ts',
29
+ '.tsx',
30
+ '.txt',
31
+ '.yaml',
32
+ '.yml'
33
+ ]);
34
+ function sourceExtension(filePath) {
35
+ const name = filePath.slice(filePath.lastIndexOf('/') + 1);
36
+ const dot = name.lastIndexOf('.');
37
+ return dot === -1 ? '' : name.slice(dot).toLowerCase();
38
+ }
39
+ /** True when a file is a text source format this viewer is allowed to expose. */
40
+ export function isPreviewableSource(filePath) {
41
+ return SOURCE_EXTENSIONS.has(sourceExtension(filePath));
42
+ }
43
+ /**
44
+ * Parse a source link without treating ordinary PWA routes as file references.
45
+ * The extension allowlist keeps links such as `/w/a-workspace` in the browser,
46
+ * while also preventing this endpoint from becoming a generic file reader.
47
+ */
48
+ export function parseFileReference(reference) {
49
+ if (!reference.startsWith('/'))
50
+ return null;
51
+ const location = reference.match(/:([1-9]\d*)(?::\d+)?$/);
52
+ const line = location ? Number(location[1]) : null;
53
+ if (line !== null && !Number.isSafeInteger(line))
54
+ return null;
55
+ const filePath = location ? reference.slice(0, -location[0].length) : reference;
56
+ if (!isPreviewableSource(filePath))
57
+ return null;
58
+ return { path: filePath, line };
59
+ }
@@ -54,7 +54,7 @@ export class ParkedPromptQueue {
54
54
  * Park a prompt (idempotent per chat + text, so a retap while locked revives
55
55
  * the entry instead of doubling it) and start watching for the unlock.
56
56
  */
57
- park(workspaceId, sessionId, text, agent) {
57
+ park(workspaceId, sessionId, text, agent, queue = false) {
58
58
  const existing = this.entries.find(e => e.sessionId === sessionId && e.text.trim() === text.trim());
59
59
  if (existing) {
60
60
  // A re-park is the user asking again: back to waiting with a clean slate.
@@ -62,6 +62,7 @@ export class ParkedPromptQueue {
62
62
  existing.attempts = 0;
63
63
  existing.error = undefined;
64
64
  existing.agent = agent ?? existing.agent;
65
+ existing.queue = queue;
65
66
  this.save();
66
67
  void this.pump();
67
68
  return existing;
@@ -71,6 +72,7 @@ export class ParkedPromptQueue {
71
72
  sessionId,
72
73
  text,
73
74
  agent,
75
+ queue,
74
76
  status: 'waiting',
75
77
  attempts: 0,
76
78
  createdAt: Date.now(),
@@ -45,6 +45,8 @@ export const routes = {
45
45
  search: flat('GET', '/api/search'),
46
46
  repos: flat('GET', '/api/repos'),
47
47
  repoIcon: param('GET', '/api/repos/:repo/icon'),
48
+ /** A source-file preview linked from agent Markdown. */
49
+ filePreview: param('GET', '/api/files/:reference'),
48
50
  logs: flat('GET', '/api/logs'),
49
51
  settings: flat('GET', '/api/settings'),
50
52
  updateSettings: flat('PATCH', '/api/settings'),
@@ -7,6 +7,7 @@ import { writeAttachment } from "./attachments.js";
7
7
  import { startAutoUpdate, updateStatus } from "./autoupdate.js";
8
8
  import { loadConfig, stateDir } from "./config.js";
9
9
  import { ConductorDb } from "./db.js";
10
+ import { parseFileReference } from "./file-preview.js";
10
11
  import { FirstPromptQueue } from "./firstprompt.js";
11
12
  import { startFunnelWatchdog } from "./funnel-watchdog.js";
12
13
  import { workspaceDiff } from "./git.js";
@@ -226,7 +227,7 @@ async function confirmAgentOptions(ws, sessionId, opts) {
226
227
  * minutes schedule sits *outside* this and is unaffected — it retries a delivery that
227
228
  * never got off the ground (worktree still setting up), not one Conductor fumbled.
228
229
  */
229
- async function deliverPrompt(ws, sessionId, text, budgetMs = SEND_BUDGET_MS) {
230
+ async function deliverPrompt(ws, sessionId, text, budgetMs = SEND_BUDGET_MS, queue = false) {
230
231
  const located = locateChat(ws, sessionId);
231
232
  if ('error' in located)
232
233
  return { ok: false, strategy: actuator.name, attempts: 0, error: located.error };
@@ -243,7 +244,10 @@ async function deliverPrompt(ws, sessionId, text, budgetMs = SEND_BUDGET_MS) {
243
244
  // write, and only the run knows what was left of the budget when it started. Minus
244
245
  // the confirm, so a caller on a tight budget spends it on the run rather than on
245
246
  // watching — a 25s-era phone gets one full-length attempt, not two too short to finish.
246
- last = await actuator.send({ workspace: ws, sessionId, tab: located.tab }, text, deadline - MIN_CONFIRM_MS);
247
+ last = await actuator.send({ workspace: ws, sessionId, tab: located.tab }, text, {
248
+ deadline: deadline - MIN_CONFIRM_MS,
249
+ queue
250
+ });
247
251
  if (await confirmDelivery(sessionId, text, beforeRowid, deadline)) {
248
252
  if (attempts > 1)
249
253
  console.info(`[relay] send to ${label} landed on attempt ${attempts}`);
@@ -359,7 +363,7 @@ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompt
359
363
  if (!applied.ok)
360
364
  return { ok: false, error: applied.error, blocked: lockBlocked(applied.error) };
361
365
  }
362
- const result = await deliverPrompt(ws, entry.sessionId, entry.text);
366
+ const result = await deliverPrompt(ws, entry.sessionId, entry.text, SEND_BUDGET_MS, entry.queue);
363
367
  return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
364
368
  }),
365
369
  notify: (entry, error) => {
@@ -386,6 +390,72 @@ const MIME = {
386
390
  '.svg': 'image/svg+xml',
387
391
  '.png': 'image/png'
388
392
  };
393
+ /** A preview stays small enough to render smoothly in the phone's source sheet. */
394
+ const FILE_PREVIEW_MAX_BYTES = 512 * 1024;
395
+ const FILE_PREVIEW_CONTEXT_LINES = 100;
396
+ const FILE_PREVIEW_FIRST_LINES = 500;
397
+ /** True only for a descendant. A prefix test would let `/workspaces-old` escape `/workspaces`. */
398
+ function insideDirectory(filePath, root) {
399
+ const relative = path.relative(root, filePath);
400
+ return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
401
+ }
402
+ /**
403
+ * Serve source that an agent linked in its Markdown. The link format comes from
404
+ * coding-agent file references, but its path still arrives from an internet-facing
405
+ * client. Resolve both sides and accept only regular source files below the
406
+ * configured Conductor workspace root.
407
+ */
408
+ async function serveFilePreview(req, res, reference) {
409
+ const target = parseFileReference(reference);
410
+ if (!target)
411
+ return json(req, res, 404, { error: 'source file not found' });
412
+ let filePath;
413
+ let workspaceRoot;
414
+ let size;
415
+ try {
416
+ ;
417
+ [filePath, workspaceRoot] = await Promise.all([
418
+ fs.promises.realpath(target.path),
419
+ fs.promises.realpath(cfg.workspacesRoot)
420
+ ]);
421
+ if (!insideDirectory(filePath, workspaceRoot))
422
+ return json(req, res, 404, { error: 'source file not found' });
423
+ const info = await fs.promises.stat(filePath);
424
+ if (!info.isFile())
425
+ return json(req, res, 404, { error: 'source file not found' });
426
+ size = info.size;
427
+ }
428
+ catch {
429
+ return json(req, res, 404, { error: 'source file not found' });
430
+ }
431
+ if (size > FILE_PREVIEW_MAX_BYTES)
432
+ return json(req, res, 413, { error: 'source file is too large to preview' });
433
+ let content;
434
+ try {
435
+ const raw = await fs.promises.readFile(filePath);
436
+ if (raw.includes(0))
437
+ return json(req, res, 415, { error: 'source file is not text' });
438
+ content = new TextDecoder('utf-8', { fatal: true }).decode(raw);
439
+ }
440
+ catch {
441
+ return json(req, res, 415, { error: 'source file is not text' });
442
+ }
443
+ const lines = content.split('\n');
444
+ const focus = target.line === null ? null : Math.min(target.line, lines.length);
445
+ const start = focus === null ? 0 : Math.max(0, focus - FILE_PREVIEW_CONTEXT_LINES - 1);
446
+ const end = focus === null
447
+ ? Math.min(lines.length, FILE_PREVIEW_FIRST_LINES)
448
+ : Math.min(lines.length, focus + FILE_PREVIEW_CONTEXT_LINES);
449
+ return json(req, res, 200, {
450
+ path: target.path,
451
+ line: focus,
452
+ lineStart: start + 1,
453
+ lineEnd: end,
454
+ totalLines: lines.length,
455
+ content: lines.slice(start, end).join('\n'),
456
+ truncated: start > 0 || end < lines.length
457
+ });
458
+ }
389
459
  /**
390
460
  * Successful GETs are conditional + compressed to keep the phone's polling cheap.
391
461
  * `no-cache` (not `no-store`) means the browser must revalidate on every tick —
@@ -814,6 +884,11 @@ const server = http.createServer(async (req, res) => {
814
884
  res.end(data);
815
885
  });
816
886
  }
887
+ // GET /api/files/:reference — source linked from an agent reply. The Markdown component
888
+ // intercepts the browser navigation and fetches this endpoint with its auth header.
889
+ const fileReference = routeParam(routes.filePreview, req.method, pathname);
890
+ if (fileReference)
891
+ return serveFilePreview(req, res, fileReference);
817
892
  // GET /api/workspaces/:id — one workspace by id, archived included. `/api/state` lists
818
893
  // only the live ones, so this is what lets the phone open a chat search found in work
819
894
  // that has been put away: the worktree is gone, the transcript is not.
@@ -1001,6 +1076,7 @@ const server = http.createServer(async (req, res) => {
1001
1076
  // rather than extending it past what the phone said it would wait.
1002
1077
  const deadline = Date.now() + sendBudget(req);
1003
1078
  const agent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
1079
+ const queue = body.queue === true;
1004
1080
  if (agent?.effort && !EFFORT_LABELS[agent.effort]) {
1005
1081
  return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
1006
1082
  }
@@ -1016,7 +1092,7 @@ const server = http.createServer(async (req, res) => {
1016
1092
  const applied = await applyAgentPatch(ws, sessionId, agent);
1017
1093
  if (!applied.ok) {
1018
1094
  if (lockBlocked(applied.error)) {
1019
- const queued = parkedPrompts.park(ws.id, sessionId, text, agent);
1095
+ const queued = parkedPrompts.park(ws.id, sessionId, text, agent, queue);
1020
1096
  return {
1021
1097
  status: 202,
1022
1098
  body: { ok: false, parked: true, queued, strategy: actuator.name, error: PARKED_ERROR }
@@ -1027,7 +1103,7 @@ const server = http.createServer(async (req, res) => {
1027
1103
  }
1028
1104
  // Retries live inside deliverPrompt, confirmed against the transcript each time,
1029
1105
  // and inside the deadline this phone told us it would wait.
1030
- const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now());
1106
+ const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now(), queue);
1031
1107
  if (result.ok) {
1032
1108
  // Whatever a queue was still holding has now been said by hand — the first
1033
1109
  // prompt (including a failed entry retried from the chat), and any parked
@@ -1038,7 +1114,7 @@ const server = http.createServer(async (req, res) => {
1038
1114
  }
1039
1115
  if (lockBlocked(result.error)) {
1040
1116
  // Settings (if any) already stuck, so the entry parks without them.
1041
- const queued = parkedPrompts.park(ws.id, sessionId, text);
1117
+ const queued = parkedPrompts.park(ws.id, sessionId, text, undefined, queue);
1042
1118
  return {
1043
1119
  status: 202,
1044
1120
  body: { ok: false, parked: true, queued, strategy: result.strategy, error: PARKED_ERROR }
@@ -166,7 +166,7 @@ export async function sidecarAvailable() {
166
166
  * Deliver a prompt to a specific session — the real send path, precisely
167
167
  * targeted. Resolves once the sidecar has accepted (queued/sent) the message.
168
168
  */
169
- export async function sidecarSendUserMessage(sessionId, text) {
169
+ export async function sidecarSendUserMessage(sessionId, text, deliveryMode = 'default') {
170
170
  await rpc('query', {
171
171
  type: 'sendUserMessageRequest',
172
172
  ...LOCAL_AUTH,
@@ -174,7 +174,7 @@ export async function sidecarSendUserMessage(sessionId, text) {
174
174
  id: randomUUID(),
175
175
  message: text,
176
176
  agentMessage: text,
177
- deliveryMode: 'default'
177
+ deliveryMode
178
178
  });
179
179
  }
180
180
  /**
@@ -195,12 +195,12 @@ export class SidecarActuator {
195
195
  return sidecarAvailable();
196
196
  }
197
197
  /** `deadline` is ignored — the sidecar is one socket write, with no UI to wait on. */
198
- async send(target, text, _deadline) {
198
+ async send(target, text, options = {}) {
199
199
  const sessionId = target.sessionId ?? target.workspace.active_session_id;
200
200
  if (!sessionId)
201
201
  return { ok: false, strategy: this.name, error: 'no session id to target' };
202
202
  try {
203
- await sidecarSendUserMessage(sessionId, text);
203
+ await sidecarSendUserMessage(sessionId, text, options.queue ? 'queue' : 'default');
204
204
  return { ok: true, strategy: this.name };
205
205
  }
206
206
  catch (err) {
@@ -347,7 +347,8 @@ export class AppleScriptActuator {
347
347
  name = 'applescript';
348
348
  caveat = "Opens the target workspace's own Conductor link, then confirms the chat tab before sending.";
349
349
  precise = true;
350
- async send(target, text, deadline = Date.now() + SEND_ATTEMPT_MS) {
350
+ async send(target, text, options = {}) {
351
+ const deadline = options.deadline ?? Date.now() + SEND_ATTEMPT_MS;
351
352
  // Open the target workspace's own link, confirm its chat tab, fill the composer, send.
352
353
  // Filling is an Accessibility write (no keystrokes, no clipboard); the
353
354
  // clipboard paste is kept only as a fallback, and stashes/restores around it.
@@ -365,7 +366,11 @@ if not (my fillComposer(promptText)) then
365
366
  set the clipboard to savedClipboard
366
367
  end if
367
368
  tell application "System Events"
368
- key code 36
369
+ if (system attribute "RELAY_QUEUE_PROMPT") is "1" then
370
+ key code 36 using {command down}
371
+ else
372
+ key code 36
373
+ end if
369
374
  end tell
370
375
  `.trim();
371
376
  // Pass the prompt via a temp file + env to avoid AppleScript string escaping.
@@ -376,7 +381,12 @@ end tell
376
381
  await fs.writeFile(tmp, text, 'utf8');
377
382
  try {
378
383
  await uiTurn(() => exec('osascript', ['-e', script], {
379
- env: { ...process.env, RELAY_PROMPT_FILE: tmp, ...targetEnv(target) },
384
+ env: {
385
+ ...process.env,
386
+ RELAY_PROMPT_FILE: tmp,
387
+ RELAY_QUEUE_PROMPT: options.queue ? '1' : '',
388
+ ...targetEnv(target)
389
+ },
380
390
  timeout: runCeiling(deadline)
381
391
  }));
382
392
  return { ok: true, strategy: this.name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.48.0",
3
+ "version": "1.49.1",
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.",
@@ -50,13 +50,14 @@
50
50
  "typecheck": "tsc -p tsconfig.json",
51
51
  "lint": "biome check .",
52
52
  "fix": "biome check . --fix",
53
- "verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:config && yarn check:model-labels && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify",
53
+ "verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:config && yarn check:file-links && yarn check:model-labels && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify",
54
54
  "release": "semantic-release",
55
55
  "prepack": "yarn build && yarn build:node",
56
56
  "postinstall": "husky || true",
57
57
  "check:attachments": "node scripts/check-attachments.ts",
58
58
  "check:applescript": "node scripts/check-applescript.ts",
59
59
  "check:config": "node scripts/check-config.ts",
60
+ "check:file-links": "node scripts/check-file-links.ts",
60
61
  "check:firstprompt": "node scripts/check-firstprompt.ts",
61
62
  "check:model-labels": "node scripts/check-model-labels.ts",
62
63
  "check:sendonce": "node scripts/check-sendonce.ts",