conductor-remote 1.28.0 → 1.29.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-ChC5JHf_.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-DnE5ulTu.css">
16
+ <script type="module" crossorigin src="/assets/index-CMY7LrSa.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-Bgd8PTCo.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 n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const c=e=>n(e,o),t={module:{uri:o},exports:l,require:c};i[o]=Promise.all(s.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:"a23ebd6c333ee14357fb2a1cd7ab440f"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DnE5ulTu.css",revision:null},{url:"assets/index-ChC5JHf_.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,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:"7509e3a7e0a41800111c781fa321fc51"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-CMY7LrSa.js",revision:null},{url:"assets/index-Bgd8PTCo.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,146 @@
1
+ /**
2
+ * The relay's own log, made readable from the phone.
3
+ *
4
+ * Nothing here changes *what* the relay logs — it changes where that output can be read from.
5
+ * `installLogCapture()` wraps `console.*` so every line lands in two places: a bounded in-memory
6
+ * ring (timestamped, level-tagged, served by `GET /api/logs`) and stdout/stderr exactly as before,
7
+ * now prefixed with a stamp. The prefix is what makes the *on-disk* daemon log parseable back into
8
+ * the same shape, so "what happened before this process started" (a crash + KeepAlive respawn —
9
+ * precisely the thing you can't see from a phone) reads like the live buffer.
10
+ *
11
+ * Two facts to keep in mind when touching this:
12
+ * - **The daemon's log contains the access token** (the startup banner prints the phone URL with
13
+ * `#token=…`). Everything served out of here goes through `redactSecrets` first — the endpoint is
14
+ * token-gated, so that isn't an escalation, but the whole point of the feature is copy-pasting
15
+ * logs into a bug report, and that must not ship the key to the relay.
16
+ * - **The files belong to the LaunchAgent, not necessarily to this process.** Only the managed
17
+ * daemon has them as its stdout/stderr; a dev `yarn start` writes to a terminal. Callers get
18
+ * `managed` so they can say whose log they're reading rather than implying it's this one's.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import util from 'node:util';
24
+ /** Kept small enough that a phone on a slow link can pull the whole thing; ~a day of a quiet relay. */
25
+ const CAPACITY = 600;
26
+ /** One AppleScript failure can carry a long stack; keep a single entry from crowding out the rest. */
27
+ const MAX_TEXT = 4000;
28
+ /** Read window for a file tail — enough for a few restarts, small enough to parse per request. */
29
+ const MAX_TAIL_BYTES = 256 * 1024;
30
+ /** The LaunchAgent's stdout / stderr, in the order they're offered to the UI (see scripts/service.ts). */
31
+ export const LOG_FILE_NAMES = ['relay.log', 'relay.err.log'];
32
+ const ring = [];
33
+ const startedAt = Date.now();
34
+ function stampText(d) {
35
+ const p = (n) => String(n).padStart(2, '0');
36
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
37
+ }
38
+ /** `2026-08-04 15:02:11 WARN …` — level only when it isn't `info`, so the banner stays readable in a terminal. */
39
+ const STAMPED = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?: (WARN|ERROR))? /;
40
+ function prefixed(text, level, at) {
41
+ // Several call sites open with a blank line to separate a block; keep that ahead of the stamp.
42
+ const lead = /^\n*/.exec(text)?.[0] ?? '';
43
+ const tag = level === 'info' ? '' : `${level.toUpperCase()} `;
44
+ return `${lead}${stampText(at)} ${tag}${text.slice(lead.length)}`;
45
+ }
46
+ function push(level, text, at) {
47
+ ring.push({ t: at, level, text: text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}… [truncated]` : text });
48
+ if (ring.length > CAPACITY)
49
+ ring.splice(0, ring.length - CAPACITY);
50
+ }
51
+ let installed = false;
52
+ /**
53
+ * Route `console.*` through the ring buffer. Call once, as early as possible — anything logged
54
+ * before it (a module-level warning in an import) only reaches the file, not `/api/logs`.
55
+ */
56
+ export function installLogCapture() {
57
+ if (installed)
58
+ return;
59
+ installed = true;
60
+ const wrap = (level, original) => {
61
+ return (...args) => {
62
+ const at = new Date();
63
+ const text = util.format(...args);
64
+ push(level, text, at.getTime());
65
+ original(prefixed(text, level, at));
66
+ };
67
+ };
68
+ console.log = wrap('info', console.log.bind(console));
69
+ console.info = wrap('info', console.info.bind(console));
70
+ console.warn = wrap('warn', console.warn.bind(console));
71
+ console.error = wrap('error', console.error.bind(console));
72
+ }
73
+ /** When this relay process started — the phone's cue that a restart, not a network blip, ate the history. */
74
+ export function processStartedAt() {
75
+ return startedAt;
76
+ }
77
+ /** Is this the launchd-supervised instance, i.e. are the log files below actually *this* process's output? */
78
+ export function isManaged() {
79
+ return process.env.CONDUCTOR_REMOTE_MANAGED === '1';
80
+ }
81
+ export function recentLogs(limit) {
82
+ return ring.slice(Math.max(0, ring.length - limit));
83
+ }
84
+ /** Where `scripts/service.ts` points the LaunchAgent's stdout/stderr. */
85
+ export function logDir() {
86
+ return path.join(os.homedir(), 'Library', 'Logs', 'conductor-remote');
87
+ }
88
+ export function logFiles() {
89
+ const dir = logDir();
90
+ const out = [];
91
+ for (const name of LOG_FILE_NAMES) {
92
+ try {
93
+ const st = fs.statSync(path.join(dir, name));
94
+ out.push({ name, size: st.size, modifiedAt: st.mtimeMs });
95
+ }
96
+ catch {
97
+ // not written yet (no LaunchAgent, or nothing on that stream) — omit it rather than offering an empty tab
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ /** Last `maxBytes` of a file, dropping the leading partial line (which is also what fixes a split UTF-8 char). */
103
+ function readTail(file, maxBytes) {
104
+ const fd = fs.openSync(file, 'r');
105
+ try {
106
+ const size = fs.fstatSync(fd).size;
107
+ const start = Math.max(0, size - maxBytes);
108
+ const buf = Buffer.alloc(size - start);
109
+ if (buf.length)
110
+ fs.readSync(fd, buf, 0, buf.length, start);
111
+ const text = buf.toString('utf8');
112
+ return start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
113
+ }
114
+ finally {
115
+ fs.closeSync(fd);
116
+ }
117
+ }
118
+ /**
119
+ * Tail one of the daemon's log files as entries. Lines written since capture was installed carry their
120
+ * own stamp and level and are restored exactly; older or continuation lines fall back to the stream's
121
+ * meaning — stderr is where both `console.warn` and `console.error` land, so it reads as `warn`.
122
+ */
123
+ export function tailLogFile(name, limit) {
124
+ const fallback = name === 'relay.err.log' ? 'warn' : 'info';
125
+ const raw = readTail(path.join(logDir(), name), MAX_TAIL_BYTES);
126
+ const lines = raw.split('\n');
127
+ if (lines[lines.length - 1] === '')
128
+ lines.pop();
129
+ return lines.slice(Math.max(0, lines.length - limit)).map(line => {
130
+ const m = STAMPED.exec(line);
131
+ if (!m)
132
+ return { t: null, level: fallback, text: line };
133
+ const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
134
+ const level = m[7] === 'ERROR' ? 'error' : m[7] === 'WARN' ? 'warn' : 'info';
135
+ return { t: t.getTime(), level, text: line.slice(m[0].length) };
136
+ });
137
+ }
138
+ /**
139
+ * Strip the access token out of anything about to leave the machine. The startup banner prints it
140
+ * verbatim, so a copied log would otherwise hand over the relay. Also masks any other `token=…`
141
+ * (a rotated token still sitting in an older line, a Tailscale URL) — a false positive costs nothing.
142
+ */
143
+ export function redactSecrets(text, token) {
144
+ const masked = token ? text.split(token).join('<token>') : text;
145
+ return masked.replace(/(token=)[^\s&"'`]+/gi, '$1<token>');
146
+ }
@@ -8,11 +8,15 @@ import { loadConfig } from "./config.js";
8
8
  import { ConductorDb } from "./db.js";
9
9
  import { startFunnelWatchdog } from "./funnel-watchdog.js";
10
10
  import { workspaceDiff } from "./git.js";
11
+ import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
11
12
  import { mergePr } from "./merge.js";
12
13
  import { attachPrStatus } from "./pr.js";
13
14
  import { Reads } from "./reads.js";
14
15
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
15
16
  import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, newChat, pickActuator, setAgentOptions, setRestartGuard } from "./writes.js";
17
+ // Before anything that logs: from here on every console line is also kept in memory for
18
+ // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
19
+ installLogCapture();
16
20
  const cfg = loadConfig();
17
21
  const db = new ConductorDb(cfg.dbPath);
18
22
  const reads = new Reads(db, cfg.workspacesRoot);
@@ -219,6 +223,35 @@ const server = http.createServer(async (req, res) => {
219
223
  if (req.method === 'GET' && pathname === '/api/repos') {
220
224
  return json(req, res, 200, { repos: reads.listRepos() });
221
225
  }
226
+ // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
227
+ // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
228
+ // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
229
+ // process's crash survives. Everything is redacted: the startup banner prints the token.
230
+ if (req.method === 'GET' && pathname === '/api/logs') {
231
+ const file = url.searchParams.get('file');
232
+ if (file && !LOG_FILE_NAMES.includes(file)) {
233
+ return json(req, res, 404, { error: `unknown log file ${file}`, files: LOG_FILE_NAMES });
234
+ }
235
+ const asked = Number(url.searchParams.get('limit') ?? 300);
236
+ const limit = Number.isFinite(asked) ? Math.min(2000, Math.max(1, Math.trunc(asked))) : 300;
237
+ let entries;
238
+ try {
239
+ entries = file ? tailLogFile(file, limit) : recentLogs(limit);
240
+ }
241
+ catch (err) {
242
+ // The file only exists once the LaunchAgent has run; say so instead of a bare 500.
243
+ return json(req, res, 404, { error: `can’t read ${file}: ${err instanceof Error ? err.message : err}` });
244
+ }
245
+ return json(req, res, 200, {
246
+ source: file ?? 'live',
247
+ // False → the files below are some *other* (daemon) process's output, not this relay's.
248
+ managed: isManaged(),
249
+ startedAt: processStartedAt(),
250
+ now: Date.now(),
251
+ files: logFiles(),
252
+ entries: entries.map(e => ({ ...e, text: redactSecrets(e.text, cfg.token) }))
253
+ });
254
+ }
222
255
  // POST /api/workspaces { repo, prompt, send? } — create a workspace via Conductor's deep link
223
256
  if (req.method === 'POST' && pathname === '/api/workspaces') {
224
257
  const body = JSON.parse((await readBody(req)) || '{}');
@@ -400,12 +433,17 @@ const server = http.createServer(async (req, res) => {
400
433
  const beforeRowid = reads.getMessages(sessionId).cursor;
401
434
  const result = await actuator.send({ workspace: ws, sessionId, tab }, text);
402
435
  if (result.ok && !(await confirmDelivery(sessionId, text, beforeRowid))) {
436
+ // The phone only ever sees "try again"; the reason a send goes missing lives
437
+ // on this side, so leave a trail in relay.log rather than nothing at all.
438
+ console.warn(`[relay] send to ${ws.branch ?? ws.id} drove the UI but never landed in the chat`);
403
439
  return json(req, res, 502, {
404
440
  ok: false,
405
441
  strategy: result.strategy,
406
442
  error: 'Send didn’t land in the chat — Conductor may have been asleep or unfocused. Try again.'
407
443
  });
408
444
  }
445
+ if (!result.ok)
446
+ console.warn(`[relay] send to ${ws.branch ?? ws.id} failed: ${result.error}`);
409
447
  return json(req, res, result.ok ? 200 : 502, result);
410
448
  }
411
449
  return json(req, res, 404, { error: 'no route', pathname });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.28.0",
3
+ "version": "1.29.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.",