conductor-remote 1.66.0 → 1.68.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.
@@ -1,7 +1,9 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { isPreviewableSource } from "./shared.js";
3
4
  const exec = promisify(execFile);
4
5
  const MAX_PATCH_BYTES = 400_000;
6
+ const MAX_LISTED_FILES = 20_000;
5
7
  async function git(cwd, args) {
6
8
  const { stdout } = await exec('git', ['-C', cwd, ...args], {
7
9
  encoding: 'utf8',
@@ -116,3 +118,27 @@ async function localState(worktree) {
116
118
  }
117
119
  return { dirty, unpushed };
118
120
  }
121
+ /**
122
+ * Every file in the worktree the phone may turn a chat mention into a link for.
123
+ *
124
+ * Agents name files in prose all day — "updated `tests/foo.ts`" — and the phone
125
+ * links a mention only when it matches a real file, so this is the list it matches
126
+ * against. Tracked plus untracked-not-ignored: an agent that just wrote a file
127
+ * names it in the same message, long before anything commits it.
128
+ *
129
+ * Two things keep the payload small. Only previewable extensions ship, because
130
+ * `/api/files` refuses everything else anyway, and 20,000 paths is the ceiling — a
131
+ * repo whose build output isn't ignored would otherwise send its whole `node_modules`
132
+ * to a phone. `-z`, because a path may legally contain a newline.
133
+ */
134
+ export async function listSourceFiles(worktree) {
135
+ let listing = '';
136
+ try {
137
+ listing = await git(worktree, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
138
+ }
139
+ catch {
140
+ return { files: [], truncated: false };
141
+ }
142
+ const files = listing.split('\0').filter(p => p !== '' && isPreviewableSource(p));
143
+ return { files: files.slice(0, MAX_LISTED_FILES), truncated: files.length > MAX_LISTED_FILES };
144
+ }
@@ -613,7 +613,7 @@ export function createTools(call) {
613
613
  },
614
614
  {
615
615
  name: 'set_workspace_status',
616
- description: 'Set a workspace’s status in Conductor’s sidebar (backlog, in-progress, in-review, done, canceled). Drives the real UI through the sidebar row menu, but changes nothing on screen. Fails if the sidebar section holding that row is collapsed, because a collapsed row is invisible to Accessibility and there is no fallback.',
616
+ description: 'Set a workspace’s status in Conductor’s sidebar (backlog, in-progress, in-review, done, canceled). Drives the real UI through the sidebar row menu, but changes nothing on screen. A collapsed section hides its rows from Accessibility, so folded sections are opened, used, and folded back which makes this run a few seconds longer.',
617
617
  inputSchema: {
618
618
  type: 'object',
619
619
  properties: {
@@ -81,6 +81,8 @@ export const routes = {
81
81
  sessions: param('GET', '/api/workspaces/:workspaceId/sessions'),
82
82
  newChat: param('POST', '/api/workspaces/:workspaceId/sessions'),
83
83
  diff: param('GET', '/api/workspaces/:workspaceId/diff'),
84
+ /** The worktree's own file list, which is what makes a chat's `src/foo.ts` a link. */
85
+ workspaceFiles: param('GET', '/api/workspaces/:workspaceId/files'),
84
86
  merge: param('POST', '/api/workspaces/:workspaceId/merge'),
85
87
  workspaceStatus: param('POST', '/api/workspaces/:workspaceId/status'),
86
88
  /** Read, start/forward, or stop a local workspace's selected Conductor Run task. */
@@ -12,7 +12,7 @@ import { DevServerController } from "./dev-server.js";
12
12
  import { isAllowedPreviewPath, parseFileReference } from "./file-preview.js";
13
13
  import { FirstPromptQueue } from "./firstprompt.js";
14
14
  import { startFunnelWatchdog } from "./funnel-watchdog.js";
15
- import { workspaceDiff } from "./git.js";
15
+ import { listSourceFiles, workspaceDiff } from "./git.js";
16
16
  import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
17
17
  import { createTools, handleRpc, READ_TIMEOUT_MS } from "./mcp-tools.js";
18
18
  import { mergePr } from "./merge.js";
@@ -489,6 +489,10 @@ async function serveFilePreview(req, res, reference) {
489
489
  const target = parseFileReference(reference);
490
490
  if (!target)
491
491
  return json(req, res, 404, { error: 'source file not found' });
492
+ const refused = (filePath) => {
493
+ const answer = previewRefusal(filePath);
494
+ return json(req, res, answer.status, { error: answer.error });
495
+ };
492
496
  let filePath;
493
497
  let workspaceRoot;
494
498
  let homeRoot;
@@ -503,7 +507,7 @@ async function serveFilePreview(req, res, reference) {
503
507
  fs.promises.realpath(BUNDLED_SKILLS_ROOT).catch(() => null)
504
508
  ]);
505
509
  if (!isAllowedPreviewPath(filePath, workspaceRoot, homeRoot, readExposeMode(), bundledSkillsRoot)) {
506
- return json(req, res, 404, { error: 'source file not found' });
510
+ return refused(filePath);
507
511
  }
508
512
  const info = await fs.promises.stat(filePath);
509
513
  if (!info.isFile())
@@ -511,7 +515,9 @@ async function serveFilePreview(req, res, reference) {
511
515
  size = info.size;
512
516
  }
513
517
  catch {
514
- return json(req, res, 404, { error: 'source file not found' });
518
+ // A path this relay would refuse must answer the same whether or not it is there, or
519
+ // a public client learns which home files exist by watching 404 turn into 403.
520
+ return refused(target.path);
515
521
  }
516
522
  if (size > FILE_PREVIEW_MAX_BYTES)
517
523
  return json(req, res, 413, { error: 'source file is too large to preview' });
@@ -541,6 +547,28 @@ async function serveFilePreview(req, res, reference) {
541
547
  truncated: start > 0 || end < lines.length
542
548
  });
543
549
  }
550
+ /**
551
+ * How to answer for a file the preview will not serve, from the path alone.
552
+ *
553
+ * Chat mentions made the home-directory case ordinary — agents write "plan written to
554
+ * `~/.gstack/plan.md`" constantly — and on a public funnel every one of those is refused
555
+ * by policy. Answering "source file not found" then sends someone hunting for a file that
556
+ * is sitting right there, so a refusal says it is a refusal. It discloses nothing: the
557
+ * verdict comes from the path and the funnel's posture, never from the disk, and it is
558
+ * the answer for an out-of-bounds path whether or not that path exists.
559
+ */
560
+ function previewRefusal(filePath) {
561
+ const mode = readExposeMode();
562
+ if (isAllowedPreviewPath(path.resolve(filePath), cfg.workspacesRoot, os.homedir(), mode, BUNDLED_SKILLS_ROOT)) {
563
+ return { status: 404, error: 'source file not found' };
564
+ }
565
+ return {
566
+ status: 403,
567
+ error: mode === 'public'
568
+ ? 'this relay is reachable from the internet, so it previews files inside Conductor workspaces only'
569
+ : 'outside the files this relay may read'
570
+ };
571
+ }
544
572
  /** Per-file ceiling for a relay exposed through Tailscale Funnel. Large media belongs in a link. */
545
573
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
546
574
  class PayloadTooLargeError extends Error {
@@ -1140,6 +1168,18 @@ const server = http.createServer(async (req, res) => {
1140
1168
  const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
1141
1169
  return json(req, res, 200, diff);
1142
1170
  }
1171
+ // GET /api/workspaces/:id/files — the worktree's own file list, which is what lets the
1172
+ // phone link `tests/foo.ts` in a message: a mention becomes a link only when it names
1173
+ // a file that is really there. A workspace with no worktree simply links nothing.
1174
+ const filesOf = routeParam(routes.workspaceFiles, req.method, pathname);
1175
+ if (filesOf) {
1176
+ const ws = reads.getWorkspace(filesOf);
1177
+ if (!ws)
1178
+ return json(req, res, 404, { error: 'workspace not found' });
1179
+ if (!ws.worktree)
1180
+ return json(req, res, 200, { files: [], truncated: false });
1181
+ return json(req, res, 200, await listSourceFiles(ws.worktree));
1182
+ }
1143
1183
  // POST /api/workspaces/:id/merge — merge the workspace's open PR (mirrors Conductor's merge button)
1144
1184
  const mergeOf = routeParam(routes.merge, req.method, pathname);
1145
1185
  if (mergeOf) {
@@ -596,9 +596,15 @@ export const WORKSPACE_STATUS_LABELS = {
596
596
  *
597
597
  * Unlike every other write here this one never changes what's on screen: it
598
598
  * right-clicks the workspace's *row* (AXShowMenu) and works the menu, so the
599
- * workspace you were reading stays open. It does need the row to be rendered,
600
- * which a collapsed sidebar section preventsthat case is reported in words
601
- * rather than guessed around, because there is no palette command to fall back to.
599
+ * workspace you were reading stays open. It does need the row to be rendered, and
600
+ * a collapsed sidebar section renders none so the script opens the folded
601
+ * sections itself, looks again, and folds back exactly the ones it opened. That
602
+ * costs a second sidebar scan, which is affordable only because that scan reads
603
+ * every row's name in two Apple events rather than one per row (15s → ~1s on a
604
+ * 50-workspace sidebar; see findSidebarRow). Measured end to end on 2026-09-01:
605
+ * 8.2s through a folded section, 5.4s through an open one. The budget is 35s
606
+ * anyway, because sidebarRowsAndNames falls back to the per-row reads when the
607
+ * list no longer matches its shape, and that path pays the old 15s twice.
602
608
  */
603
609
  export async function setWorkspaceStatus(workspace, status) {
604
610
  const label = WORKSPACE_STATUS_LABELS[status];
@@ -617,7 +623,7 @@ return "ok"`.trim();
617
623
  ...targetEnvironment,
618
624
  RELAY_SET_STATUS: label
619
625
  },
620
- timeout: 25000
626
+ timeout: 35000
621
627
  })));
622
628
  return { ok: true, strategy: 'applescript' };
623
629
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.66.0",
3
+ "version": "1.68.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.",