conductor-remote 1.15.1 → 1.15.2

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.
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import http from 'node:http';
4
4
  import path from 'node:path';
5
+ import zlib from 'node:zlib';
5
6
  import { startAutoUpdate, updateStatus } from "./autoupdate.js";
6
7
  import { loadConfig } from "./config.js";
7
8
  import { ConductorDb } from "./db.js";
@@ -22,10 +23,34 @@ const MIME = {
22
23
  '.svg': 'image/svg+xml',
23
24
  '.png': 'image/png'
24
25
  };
25
- function json(res, status, body) {
26
- const payload = JSON.stringify(body);
27
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
28
- res.end(payload);
26
+ /**
27
+ * Successful GETs are conditional + compressed to keep the phone's polling cheap.
28
+ * `no-cache` (not `no-store`) means the browser must revalidate on every tick —
29
+ * the relay still runs the handler and auth each time, so data is never stale;
30
+ * a matching ETag just elides the redundant body (304), and changed bodies over
31
+ * ~1 KB go out gzipped. Errors and non-GETs stay unconditional `no-store`.
32
+ */
33
+ function json(req, res, status, body) {
34
+ const payload = Buffer.from(JSON.stringify(body));
35
+ if (status !== 200 || req.method !== 'GET') {
36
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
37
+ return void res.end(payload);
38
+ }
39
+ // Weak: the same entity may be delivered gzipped or plain.
40
+ const etag = `W/"${crypto.createHash('sha1').update(payload).digest('base64url')}"`;
41
+ const headers = {
42
+ 'content-type': 'application/json; charset=utf-8',
43
+ 'cache-control': 'no-cache',
44
+ etag,
45
+ vary: 'accept-encoding'
46
+ };
47
+ if (req.headers['if-none-match'] === etag)
48
+ return void res.writeHead(304, headers).end();
49
+ if (payload.length > 1024 && /\bgzip\b/.test(String(req.headers['accept-encoding'] ?? ''))) {
50
+ headers['content-encoding'] = 'gzip';
51
+ return void res.writeHead(200, headers).end(zlib.gzipSync(payload));
52
+ }
53
+ res.writeHead(200, headers).end(payload);
29
54
  }
30
55
  /** Constant-time string compare — the token is the sole internet-facing gate when exposed via Funnel. */
31
56
  function tokenEq(candidate) {
@@ -85,14 +110,14 @@ const server = http.createServer(async (req, res) => {
85
110
  return serveStatic(req, res, pathname);
86
111
  // Everything under /api requires the shared secret.
87
112
  if (!authed(req))
88
- return json(res, 401, { error: 'unauthorized' });
113
+ return json(req, res, 401, { error: 'unauthorized' });
89
114
  try {
90
115
  // GET /api/state — workspace list with active-session status
91
116
  if (req.method === 'GET' && pathname === '/api/state') {
92
117
  const update = updateStatus();
93
118
  const workspaces = reads.listWorkspaces();
94
119
  attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
95
- return json(res, 200, {
120
+ return json(req, res, 200, {
96
121
  workspaces,
97
122
  actuator: await describeActuator(actuator),
98
123
  version: update.current,
@@ -104,10 +129,10 @@ const server = http.createServer(async (req, res) => {
104
129
  if (req.method === 'GET' && m) {
105
130
  const icon = reads.resolveRepoIcon(decodeURIComponent(m[1]));
106
131
  if (!icon)
107
- return json(res, 404, { error: 'no icon' });
132
+ return json(req, res, 404, { error: 'no icon' });
108
133
  return void fs.readFile(icon.path, (err, data) => {
109
134
  if (err)
110
- return void json(res, 404, { error: 'no icon' });
135
+ return void json(req, res, 404, { error: 'no icon' });
111
136
  // Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
112
137
  res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' });
113
138
  res.end(data);
@@ -116,42 +141,42 @@ const server = http.createServer(async (req, res) => {
116
141
  // GET /api/workspaces/:id/sessions
117
142
  m = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/);
118
143
  if (req.method === 'GET' && m) {
119
- return json(res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) });
144
+ return json(req, res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) });
120
145
  }
121
146
  // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
122
147
  if (req.method === 'POST' && m) {
123
148
  const workspaceId = decodeURIComponent(m[1]);
124
149
  const ws = reads.getWorkspace(workspaceId);
125
150
  if (!ws)
126
- return json(res, 404, { error: 'workspace not found' });
151
+ return json(req, res, 404, { error: 'workspace not found' });
127
152
  const before = new Set(reads.listSessions(workspaceId).map(s => s.id));
128
153
  const result = await newChat(ws);
129
154
  if (!result.ok)
130
- return json(res, 502, result);
155
+ return json(req, res, 502, result);
131
156
  // The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
132
157
  let sessionId = null;
133
158
  for (let i = 0; i < 12 && !sessionId; i++) {
134
159
  await new Promise(r => setTimeout(r, 500));
135
160
  sessionId = reads.listSessions(workspaceId).find(s => !before.has(s.id))?.id ?? null;
136
161
  }
137
- return json(res, 200, { ok: true, sessionId });
162
+ return json(req, res, 200, { ok: true, sessionId });
138
163
  }
139
164
  // GET /api/workspaces/:id/diff
140
165
  m = pathname.match(/^\/api\/workspaces\/([^/]+)\/diff$/);
141
166
  if (req.method === 'GET' && m) {
142
167
  const ws = reads.getWorkspace(decodeURIComponent(m[1]));
143
168
  if (!ws)
144
- return json(res, 404, { error: 'workspace not found' });
169
+ return json(req, res, 404, { error: 'workspace not found' });
145
170
  if (!ws.worktree)
146
- return json(res, 409, { error: 'worktree path unresolved' });
171
+ return json(req, res, 409, { error: 'worktree path unresolved' });
147
172
  const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
148
- return json(res, 200, diff);
173
+ return json(req, res, 200, diff);
149
174
  }
150
175
  // GET /api/sessions/:id/messages?after=<rowid>
151
176
  m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
152
177
  if (req.method === 'GET' && m) {
153
178
  const after = Number(url.searchParams.get('after') ?? 0);
154
- return json(res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
179
+ return json(req, res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
155
180
  }
156
181
  // POST /api/sessions/:id/prompt { text }
157
182
  m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
@@ -160,19 +185,19 @@ const server = http.createServer(async (req, res) => {
160
185
  const body = JSON.parse((await readBody(req)) || '{}');
161
186
  const text = (body.text ?? '').trim();
162
187
  if (!text)
163
- return json(res, 400, { error: 'empty prompt' });
188
+ return json(req, res, 400, { error: 'empty prompt' });
164
189
  const ws = body.workspaceId
165
190
  ? reads.getWorkspace(body.workspaceId)
166
191
  : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
167
192
  if (!ws)
168
- return json(res, 404, { error: 'workspace for session not found' });
193
+ return json(req, res, 404, { error: 'workspace for session not found' });
169
194
  const result = await actuator.send({ workspace: ws, sessionId }, text);
170
- return json(res, result.ok ? 200 : 502, result);
195
+ return json(req, res, result.ok ? 200 : 502, result);
171
196
  }
172
- return json(res, 404, { error: 'no route', pathname });
197
+ return json(req, res, 404, { error: 'no route', pathname });
173
198
  }
174
199
  catch (err) {
175
- return json(res, 500, { error: err instanceof Error ? err.message : String(err) });
200
+ return json(req, res, 500, { error: err instanceof Error ? err.message : String(err) });
176
201
  }
177
202
  });
178
203
  server.listen(cfg.port, cfg.host, () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.15.1",
3
+ "version": "1.15.2",
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.",