conductor-remote 1.65.1 → 1.67.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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Device-independent PWA state, durable on the host Mac.
3
+ *
4
+ * localStorage remains the live, offline-first copy. This store is the sync peer that
5
+ * survives a PWA origin change and lets another phone or browser pick up where the first
6
+ * stopped. It deliberately does not contain the access token, pending HTTP requests, or
7
+ * other device-scoped state.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { stateDir } from "./config.js";
12
+ const MAX_KEYS = 50_000;
13
+ const MAX_KEY_LENGTH = 256;
14
+ const MAX_MARK_LENGTH = 128;
15
+ const MAX_DRAFT_LENGTH = 1_000_000;
16
+ const MAX_AGENT_LABEL_LENGTH = 256;
17
+ function object(raw) {
18
+ return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
19
+ }
20
+ function validKey(key) {
21
+ return key.length > 0 && key.length <= MAX_KEY_LENGTH;
22
+ }
23
+ function sanitizeAgent(raw) {
24
+ const value = object(raw);
25
+ if (!value)
26
+ return {};
27
+ const agent = {};
28
+ if (typeof value.model === 'string' && value.model.length <= MAX_AGENT_LABEL_LENGTH)
29
+ agent.model = value.model;
30
+ if (typeof value.effort === 'string' && value.effort.length <= MAX_AGENT_LABEL_LENGTH)
31
+ agent.effort = value.effort;
32
+ if (typeof value.plan === 'boolean')
33
+ agent.plan = value.plan;
34
+ if (typeof value.fast === 'boolean')
35
+ agent.fast = value.fast;
36
+ return agent;
37
+ }
38
+ function sanitizeReadMarks(raw) {
39
+ const value = object(raw);
40
+ if (!value)
41
+ return {};
42
+ const entries = Object.entries(value)
43
+ .filter((entry) => {
44
+ const [key, mark] = entry;
45
+ return validKey(key) && typeof mark === 'string' && mark.length > 0 && mark.length <= MAX_MARK_LENGTH;
46
+ })
47
+ .sort((a, b) => b[1].localeCompare(a[1]))
48
+ .slice(0, MAX_KEYS);
49
+ return Object.fromEntries(entries);
50
+ }
51
+ function sanitizeDraft(raw) {
52
+ const value = object(raw);
53
+ if (!value)
54
+ return null;
55
+ const updatedAt = Number(value.updatedAt);
56
+ if (!Number.isSafeInteger(updatedAt) || updatedAt < 0)
57
+ return null;
58
+ const deleted = value.deleted === true;
59
+ if (deleted)
60
+ return { text: '', agent: {}, updatedAt, deleted: true };
61
+ if (typeof value.text !== 'string' || value.text.length > MAX_DRAFT_LENGTH)
62
+ return null;
63
+ return { text: value.text, agent: sanitizeAgent(value.agent), updatedAt, deleted: false };
64
+ }
65
+ function sanitizeDrafts(raw) {
66
+ const value = object(raw);
67
+ if (!value)
68
+ return {};
69
+ const entries = [];
70
+ for (const [key, candidate] of Object.entries(value)) {
71
+ if (!validKey(key))
72
+ continue;
73
+ const draft = sanitizeDraft(candidate);
74
+ if (draft)
75
+ entries.push([key, draft]);
76
+ }
77
+ entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt);
78
+ return Object.fromEntries(entries.slice(0, MAX_KEYS));
79
+ }
80
+ function sanitize(raw) {
81
+ const value = object(raw);
82
+ return {
83
+ readMarks: sanitizeReadMarks(value?.readMarks),
84
+ drafts: sanitizeDrafts(value?.drafts)
85
+ };
86
+ }
87
+ function sameDraft(a, b) {
88
+ return (a.text === b.text &&
89
+ a.updatedAt === b.updatedAt &&
90
+ a.deleted === b.deleted &&
91
+ a.agent.model === b.agent.model &&
92
+ a.agent.effort === b.agent.effort &&
93
+ a.agent.plan === b.agent.plan &&
94
+ a.agent.fast === b.agent.fast);
95
+ }
96
+ /** Cached, single-process JSON store. A custom file keeps its merge rules easy to test. */
97
+ export class PrefsStore {
98
+ file;
99
+ cache = null;
100
+ constructor(file = path.join(stateDir(), 'prefs.json')) {
101
+ this.file = file;
102
+ }
103
+ read() {
104
+ if (this.cache)
105
+ return this.cache;
106
+ try {
107
+ this.cache = sanitize(JSON.parse(fs.readFileSync(this.file, 'utf8')));
108
+ }
109
+ catch {
110
+ this.cache = { readMarks: {}, drafts: {} };
111
+ }
112
+ return this.cache;
113
+ }
114
+ /** Merge a client snapshot. Marks take max; draft revisions use LWW with deletion winning a tie. */
115
+ patch(raw) {
116
+ const input = object(raw);
117
+ if (!input)
118
+ return this.read();
119
+ const current = this.read();
120
+ const next = { readMarks: { ...current.readMarks }, drafts: { ...current.drafts } };
121
+ let changed = false;
122
+ if (Object.hasOwn(input, 'readMarks')) {
123
+ for (const [key, mark] of Object.entries(sanitizeReadMarks(input.readMarks))) {
124
+ if ((next.readMarks[key] ?? '') >= mark)
125
+ continue;
126
+ next.readMarks[key] = mark;
127
+ changed = true;
128
+ }
129
+ }
130
+ if (Object.hasOwn(input, 'drafts')) {
131
+ for (const [key, draft] of Object.entries(sanitizeDrafts(input.drafts))) {
132
+ const previous = next.drafts[key];
133
+ const wins = !previous ||
134
+ draft.updatedAt > previous.updatedAt ||
135
+ (draft.updatedAt === previous.updatedAt && draft.deleted && !previous.deleted);
136
+ if (!wins || (previous && sameDraft(previous, draft)))
137
+ continue;
138
+ next.drafts[key] = draft;
139
+ changed = true;
140
+ }
141
+ }
142
+ if (!changed)
143
+ return current;
144
+ this.persist(next);
145
+ this.cache = next;
146
+ return next;
147
+ }
148
+ persist(prefs) {
149
+ const dir = path.dirname(this.file);
150
+ const temporary = `${this.file}.${process.pid}.tmp`;
151
+ try {
152
+ fs.mkdirSync(dir, { recursive: true });
153
+ fs.writeFileSync(temporary, `${JSON.stringify(prefs, null, '\t')}\n`, { mode: 0o600 });
154
+ fs.renameSync(temporary, this.file);
155
+ }
156
+ catch (err) {
157
+ try {
158
+ fs.unlinkSync(temporary);
159
+ }
160
+ catch { }
161
+ console.warn(`⚠ could not persist synced preferences (${err instanceof Error ? err.message : err})`);
162
+ }
163
+ }
164
+ }
165
+ const store = new PrefsStore();
166
+ export function readPrefs() {
167
+ return store.read();
168
+ }
169
+ export function writePrefs(patch) {
170
+ return store.patch(patch);
171
+ }
@@ -64,6 +64,9 @@ export const routes = {
64
64
  logs: flat('GET', '/api/logs'),
65
65
  settings: flat('GET', '/api/settings'),
66
66
  updateSettings: flat('PATCH', '/api/settings'),
67
+ /** Durable, device-independent PWA state. localStorage remains its offline-first mirror. */
68
+ prefs: flat('GET', '/api/prefs'),
69
+ updatePrefs: flat('PATCH', '/api/prefs'),
67
70
  nosleep: flat('GET', '/api/nosleep'),
68
71
  armNoSleep: flat('POST', '/api/nosleep'),
69
72
  disarmNoSleep: flat('DELETE', '/api/nosleep'),
@@ -78,6 +81,8 @@ export const routes = {
78
81
  sessions: param('GET', '/api/workspaces/:workspaceId/sessions'),
79
82
  newChat: param('POST', '/api/workspaces/:workspaceId/sessions'),
80
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'),
81
86
  merge: param('POST', '/api/workspaces/:workspaceId/merge'),
82
87
  workspaceStatus: param('POST', '/api/workspaces/:workspaceId/status'),
83
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";
@@ -21,6 +21,7 @@ import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepS
21
21
  import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
22
22
  import { ParkedPromptQueue } from "./parked.js";
23
23
  import { attachPrStatus } from "./pr.js";
24
+ import { readPrefs, writePrefs } from "./prefs.js";
24
25
  import { Reads } from "./reads.js";
25
26
  import { isRoute, routeParam, routes } from "./routes.js";
26
27
  import { foldHits, queryTokens, SearchIndex } from "./search.js";
@@ -30,7 +31,7 @@ import { discardStagedAttachment, materializeStagedAttachments, stageAttachment,
30
31
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
31
32
  import { renderTranscript } from "./transcript.js";
32
33
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
33
- import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
34
+ import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
34
35
  // Before anything that logs: from here on every console line is also kept in memory for
35
36
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
36
37
  installLogCapture();
@@ -258,7 +259,14 @@ async function deliverPrompt(ws, sessionId, text, budgetMs = SEND_BUDGET_MS, que
258
259
  deadline: deadline - MIN_CONFIRM_MS,
259
260
  queue
260
261
  });
261
- if (await confirmDelivery(sessionId, text, beforeRowid, deadline)) {
262
+ // A run that left the prompt in the composer proved it wrote no row, so the
263
+ // window would be six seconds of watching for nothing. One check still happens:
264
+ // an *earlier* attempt's row can be arriving, and typing again over that is the
265
+ // duplicate this whole path exists to avoid.
266
+ const landed = sendNeverStarted(last.error)
267
+ ? deliveredSince(sessionId, text, beforeRowid)
268
+ : await confirmDelivery(sessionId, text, beforeRowid, deadline);
269
+ if (landed) {
262
270
  if (attempts > 1)
263
271
  console.info(`[relay] send to ${label} landed on attempt ${attempts}`);
264
272
  return { ok: true, strategy: last.strategy, attempts };
@@ -481,6 +489,10 @@ async function serveFilePreview(req, res, reference) {
481
489
  const target = parseFileReference(reference);
482
490
  if (!target)
483
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
+ };
484
496
  let filePath;
485
497
  let workspaceRoot;
486
498
  let homeRoot;
@@ -495,7 +507,7 @@ async function serveFilePreview(req, res, reference) {
495
507
  fs.promises.realpath(BUNDLED_SKILLS_ROOT).catch(() => null)
496
508
  ]);
497
509
  if (!isAllowedPreviewPath(filePath, workspaceRoot, homeRoot, readExposeMode(), bundledSkillsRoot)) {
498
- return json(req, res, 404, { error: 'source file not found' });
510
+ return refused(filePath);
499
511
  }
500
512
  const info = await fs.promises.stat(filePath);
501
513
  if (!info.isFile())
@@ -503,7 +515,9 @@ async function serveFilePreview(req, res, reference) {
503
515
  size = info.size;
504
516
  }
505
517
  catch {
506
- 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);
507
521
  }
508
522
  if (size > FILE_PREVIEW_MAX_BYTES)
509
523
  return json(req, res, 413, { error: 'source file is too large to preview' });
@@ -533,6 +547,28 @@ async function serveFilePreview(req, res, reference) {
533
547
  truncated: start > 0 || end < lines.length
534
548
  });
535
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
+ }
536
572
  /** Per-file ceiling for a relay exposed through Tailscale Funnel. Large media belongs in a link. */
537
573
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
538
574
  class PayloadTooLargeError extends Error {
@@ -842,6 +878,22 @@ const server = http.createServer(async (req, res) => {
842
878
  return json(req, res, 400, { error: 'nothing to change' });
843
879
  return json(req, res, 200, { settings: writeSettings(patch) });
844
880
  }
881
+ // PWA state remains local-first; this host copy survives origin changes and
882
+ // reconciles phones. PATCH accepts a full client snapshot and merges per key.
883
+ if (isRoute(routes.prefs, req.method, pathname)) {
884
+ return json(req, res, 200, { prefs: readPrefs() });
885
+ }
886
+ if (isRoute(routes.updatePrefs, req.method, pathname)) {
887
+ const raw = JSON.parse((await readBody(req)) || '{}');
888
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
889
+ return json(req, res, 400, { error: 'preferences must be an object' });
890
+ }
891
+ const body = raw;
892
+ if (!Object.hasOwn(body, 'readMarks') && !Object.hasOwn(body, 'drafts')) {
893
+ return json(req, res, 400, { error: 'nothing to sync' });
894
+ }
895
+ return json(req, res, 200, { prefs: writePrefs(body) });
896
+ }
845
897
  // GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
846
898
  if (isRoute(routes.nosleep, req.method, pathname)) {
847
899
  return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
@@ -1116,6 +1168,18 @@ const server = http.createServer(async (req, res) => {
1116
1168
  const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
1117
1169
  return json(req, res, 200, diff);
1118
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
+ }
1119
1183
  // POST /api/workspaces/:id/merge — merge the workspace's open PR (mirrors Conductor's merge button)
1120
1184
  const mergeOf = routeParam(routes.merge, req.method, pathname);
1121
1185
  if (mergeOf) {
@@ -150,6 +150,20 @@ export function retryWontHelp(error) {
150
150
  export function lockBlocked(error) {
151
151
  return (error ?? '').includes('The Mac is locked');
152
152
  }
153
+ /**
154
+ * A run that ended with the prompt still sitting in Conductor's composer
155
+ * (`submitComposer`). The draft was never consumed, so this run wrote no row and
156
+ * the caller's confirm window has nothing to wait for — six seconds spent watching
157
+ * for something the run already proved didn't happen. Only the *waiting* is
158
+ * skipped: an earlier attempt's row can still be arriving, so the caller checks
159
+ * once before typing again.
160
+ *
161
+ * Matched on the phrase the send script writes itself, like `lockBlocked` above,
162
+ * so macOS wording can't drift under it.
163
+ */
164
+ export function sendNeverStarted(error) {
165
+ return (error ?? '').includes('still sitting in its composer');
166
+ }
153
167
  /**
154
168
  * Node's own read of the lock screen — the same CGSessionCopyCurrentDictionary
155
169
  * probe `screenLocked()` in conductor.applescript makes, minus the AppleScript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.65.1",
3
+ "version": "1.67.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.",