conductor-remote 1.58.2 → 1.59.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,7 +13,7 @@
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-yoen_tHL.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-XDQOL8Jf.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-DT9enoqA.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
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 c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(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:"bccefb2a435fb6775bac9dc39985ea8d"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-yoen_tHL.js",revision:null},{url:"assets/index-DT9enoqA.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\//]}))});
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 c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(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:"0fcfd46a66899dffaf188c7d215cb4f3"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-XDQOL8Jf.js",revision:null},{url:"assets/index-DT9enoqA.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\//]}))});
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { chatCursor, parseChatCursor } from "./chat-cursor.js";
29
29
  import { routes } from "./routes.js";
30
- import { HIT_CLOSE, HIT_OPEN, workspaceTitle } from "./shared.js";
30
+ import { HIT_CLOSE, HIT_OPEN, isToolResult, workspaceTitle } from "./shared.js";
31
31
  /** Versions we know how to speak. The client's choice wins when we know it. */
32
32
  export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
33
33
  export const SERVER_INFO = { name: 'conductor-remote', version: '1' };
@@ -197,7 +197,18 @@ export function createTools(call) {
197
197
  if (anchor !== null && !data.entries.some(entry => entry.rowid === anchor)) {
198
198
  throw new Error('near cursor is not in that session');
199
199
  }
200
- const wanted = data.entries.filter(e => e.role === 'thinking' ? includeThinking : e.role === 'tool' ? includeTools : true);
200
+ const wanted = data.entries.filter(e => {
201
+ if (e.role === 'thinking')
202
+ return includeThinking;
203
+ // A successful result carries the call's output, which is the churn `include_tools`
204
+ // exists to keep out of a context window — the call above it already says what ran.
205
+ // A failed one stays: one line, and it is why the turn changed course.
206
+ if (isToolResult(e) && !e.error)
207
+ return false;
208
+ if (e.role === 'tool')
209
+ return includeTools;
210
+ return true;
211
+ });
201
212
  if (!wanted.length)
202
213
  return `no messages in session ${sessionId}`;
203
214
  let selected;
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { describeRepoIcon, resolveRepoIcon } from "./icons.js";
5
5
  import { workspaceTitle } from "./shared.js";
6
- import { parseMessage } from "./transcript.js";
6
+ import { parseMessage, toolImageAt } from "./transcript.js";
7
7
  /** Keep the stable wire field in sync with whichever provider owns the chat. */
8
8
  function toSessionRow(row) {
9
9
  const { codex_thinking_level, ...session } = row;
@@ -309,6 +309,24 @@ export class Reads {
309
309
  }
310
310
  return null;
311
311
  }
312
+ /**
313
+ * One image a tool returned, by the reference its transcript entry carries.
314
+ *
315
+ * The bytes sit in `session_messages.content` as base64, so this is the same read-only
316
+ * handle as everything else — nothing is written and nothing is cached to disk. The
317
+ * reference names a row and the image's position in it, and `toolImageAt` does the
318
+ * walk, because the numbering has to be the one `parseMessage` used.
319
+ */
320
+ toolImage(reference) {
321
+ const dot = reference.lastIndexOf('.');
322
+ const rowid = Number(reference.slice(0, dot));
323
+ const index = Number(reference.slice(dot + 1));
324
+ if (dot < 0 || !Number.isInteger(rowid) || !Number.isInteger(index) || index < 0)
325
+ return null;
326
+ const rows = this.db.query('SELECT content FROM session_messages WHERE rowid = ? LIMIT 1', [rowid]);
327
+ const content = rows[0]?.content;
328
+ return content ? toolImageAt(content, index) : null;
329
+ }
312
330
  /**
313
331
  * Which workspace a chat belongs to.
314
332
  *
@@ -51,6 +51,12 @@ export const routes = {
51
51
  localImage: param('GET', '/api/local-images/:path'),
52
52
  /** A source-file preview linked from agent Markdown. */
53
53
  filePreview: param('GET', '/api/files/:reference'),
54
+ /**
55
+ * One image a tool returned, addressed `<message rowid>.<image number in that row>`.
56
+ * It is a route rather than a field because the bytes are ~100 kB of base64 each and a
57
+ * transcript's first fetch carries a whole chat: the phone asks only for the ones it opens.
58
+ */
59
+ toolImage: param('GET', '/api/tool-images/:reference'),
54
60
  /** Temporarily hold a file while the phone creates the workspace it will belong to. */
55
61
  stageAttachment: flat('POST', '/api/attachments'),
56
62
  /** Drop a staged file the user removed before creating its workspace. */
@@ -1053,6 +1053,24 @@ const server = http.createServer(async (req, res) => {
1053
1053
  const localImage = routeParam(routes.localImage, req.method, pathname);
1054
1054
  if (localImage)
1055
1055
  return serveLocalImage(req, res, localImage);
1056
+ // GET /api/tool-images/:reference — a screenshot or other image a tool returned. Held
1057
+ // back from the transcript itself (~100 kB of base64 each) and fetched only for a step
1058
+ // the reader opened, with the phone's auth header, like every other image route here.
1059
+ const toolImageRef = routeParam(routes.toolImage, req.method, pathname);
1060
+ if (toolImageRef) {
1061
+ const image = reads.toolImage(toolImageRef);
1062
+ if (!image)
1063
+ return json(req, res, 404, { error: 'image not found' });
1064
+ const bytes = Buffer.from(image.data, 'base64');
1065
+ // Immutable: a transcript row is written once, so the reference names one picture
1066
+ // forever and re-opening the step costs nothing.
1067
+ res.writeHead(200, {
1068
+ 'content-type': image.mediaType,
1069
+ 'content-length': String(bytes.length),
1070
+ 'cache-control': 'private, max-age=86400, immutable'
1071
+ });
1072
+ return void res.end(bytes);
1073
+ }
1056
1074
  // GET /api/files/:reference — source linked from an agent reply. The Markdown component
1057
1075
  // intercepts the browser navigation and fetches this endpoint with its auth header.
1058
1076
  const fileReference = routeParam(routes.filePreview, req.method, pathname);
@@ -202,3 +202,15 @@ export function isPreviewableSource(filePath) {
202
202
  const dot = name.lastIndexOf('.');
203
203
  return dot !== -1 && SOURCE_EXTENSIONS.has(name.slice(dot).toLowerCase());
204
204
  }
205
+ /**
206
+ * A transcript row that carries a tool's *output* rather than the call that produced it.
207
+ *
208
+ * The two are separate `session_messages` rows and reach the phone as separate entries
209
+ * (src/transcript.ts), so three places have to agree on which is which: the phone folds
210
+ * a result onto its call, a rendered transcript prints the call and leaves the output
211
+ * behind, and `read_chat` does the same for an agent. Structural, because each of them
212
+ * holds a slightly different view of the same row.
213
+ */
214
+ export function isToolResult(e) {
215
+ return e.role === 'tool' && !e.tool && e.output !== undefined;
216
+ }
@@ -8,6 +8,7 @@
8
8
  * tool_result blocks, never the user's own words. Real prompts are the
9
9
  * plain-text rows. Never render an SDK user frame as a user bubble.
10
10
  */
11
+ import { isToolResult } from "./shared.js";
11
12
  const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
12
13
  const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
13
14
  /** Make tool details repo-relative: absolute worktree paths waste the whole line on a phone. */
@@ -35,16 +36,113 @@ function summarizeToolUse(name, input, worktree) {
35
36
  return { text };
36
37
  return { text, detail: stripWorktree(detail, worktree) };
37
38
  }
38
- function resultText(content) {
39
- let s = '';
39
+ /**
40
+ * How much of a tool's output travels to the phone.
41
+ *
42
+ * Output is the largest thing in the history — 799 MB of the 3,106 MB in
43
+ * `session_messages.content` (see src/search.ts) — and a chat's first transcript fetch
44
+ * carries the whole backlog at once, so this cap is what keeps opening a chat from
45
+ * paying for every file the agent ever read. Measured over the two biggest chats on
46
+ * this Mac: 335 results / 271 kB raw and 664 results / 1,732 kB raw, which this cap
47
+ * brings down to 193 kB and 569 kB. What is past it is worth reading in Conductor.
48
+ */
49
+ const MAX_OUTPUT_CHARS = 2000;
50
+ /**
51
+ * Read a tool result, whatever shape it came in.
52
+ *
53
+ * Measured over the 40,000 most recent result blocks on this Mac: 35,709 are a plain
54
+ * string, 3,120 are Conductor's own edit result (`{status, diffString}`), 753 are text
55
+ * blocks, 205 are `tool_reference` lists and 80 carry an image. Only the first and third
56
+ * used to be read, so **3,917 results rendered as nothing at all** — an edit's diff, the
57
+ * tools a search found, and every screenshot, each of them a step that looked like it
58
+ * did nothing. An unknown shape falls back to its own JSON rather than to silence, which
59
+ * is what keeps Conductor drift visible instead of blank.
60
+ */
61
+ function resultOutput(content, worktree) {
62
+ const plain = (text, diff = false) => ({
63
+ text: clip(text.replace(/<\/?tool_use_error>/g, '').trim(), MAX_OUTPUT_CHARS),
64
+ diff,
65
+ images: 0
66
+ });
40
67
  if (typeof content === 'string')
41
- s = content;
42
- else if (Array.isArray(content)) {
43
- s = content
44
- .map(c => (c && typeof c === 'object' && 'text' in c ? String(c.text) : ''))
45
- .join('');
68
+ return plain(content);
69
+ if (Array.isArray(content)) {
70
+ const said = [];
71
+ const tools = [];
72
+ let images = 0;
73
+ for (const raw of content) {
74
+ if (!raw || typeof raw !== 'object')
75
+ continue;
76
+ if (raw.type === 'image')
77
+ images++;
78
+ else if (str(raw.tool_name))
79
+ tools.push(str(raw.tool_name));
80
+ else if (typeof raw.text === 'string')
81
+ said.push(raw.text);
82
+ }
83
+ // A tool_reference list is the whole answer of a tool search; naming them beats the
84
+ // count, because which tools came back is the thing worth reading later.
85
+ if (tools.length)
86
+ said.push(`${plural(tools.length, 'tool')}: ${tools.join(', ')}`);
87
+ return { ...plain(said.join('')), images };
88
+ }
89
+ if (content && typeof content === 'object') {
90
+ const o = content;
91
+ // Conductor's edit result. The status names the file it wrote, so it is worth a line
92
+ // of its own above the hunks — worktree-relative, like every other path here.
93
+ const patch = str(o.diffString);
94
+ if (patch) {
95
+ const status = str(o.status);
96
+ const head = status ? `${stripWorktree(status, worktree)}\n` : '';
97
+ return plain(head + patch, true);
98
+ }
99
+ return plain(JSON.stringify(content));
100
+ }
101
+ return plain('');
102
+ }
103
+ /**
104
+ * The images one row's tool results carry, in the order the transcript numbered them.
105
+ *
106
+ * The reference on an entry is `<rowid>.<n>`, and `n` counts image blocks across every
107
+ * `tool_result` in that row — so this walk and the one in `parseMessage` must stay the
108
+ * same walk, which is why they live in one file.
109
+ */
110
+ export function toolImageAt(content, index) {
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(content);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ let seen = 0;
119
+ for (const b of parsed.message?.content ?? []) {
120
+ if (b.type !== 'tool_result' || !Array.isArray(b.content))
121
+ continue;
122
+ for (const raw of b.content) {
123
+ if (raw?.type !== 'image')
124
+ continue;
125
+ if (seen++ !== index)
126
+ continue;
127
+ const data = str(raw.source?.data);
128
+ if (!data)
129
+ return null;
130
+ return { mediaType: str(raw.source?.media_type) ?? sniffImageType(data), data };
131
+ }
46
132
  }
47
- return clip(s.replace(/<\/?tool_use_error>/g, '').trim(), 400);
133
+ return null;
134
+ }
135
+ /** Base64 magic bytes, for the image blocks that carry no `media_type`. */
136
+ function sniffImageType(base64) {
137
+ if (base64.startsWith('iVBOR'))
138
+ return 'image/png';
139
+ if (base64.startsWith('/9j/'))
140
+ return 'image/jpeg';
141
+ if (base64.startsWith('R0lGOD'))
142
+ return 'image/gif';
143
+ if (base64.startsWith('UklGR'))
144
+ return 'image/webp';
145
+ return 'application/octet-stream';
48
146
  }
49
147
  export function parseMessage(row, worktree = null) {
50
148
  const queued = row.queue_order !== null && row.sent_at === null;
@@ -86,6 +184,9 @@ export function parseMessage(row, worktree = null) {
86
184
  }
87
185
  const entries = [];
88
186
  const push = (e) => entries.push({ ...base, ...e, id: `${row.id}:${entries.length}` });
187
+ // Images are numbered per row, because that is all a reference needs to find one again
188
+ // (`toolImageAt`) and a row may hold several results.
189
+ let imageIndex = 0;
89
190
  let pending = [];
90
191
  const flush = () => {
91
192
  const text = pending.join('\n').trim();
@@ -107,12 +208,36 @@ export function parseMessage(row, worktree = null) {
107
208
  }
108
209
  else if (b.type === 'tool_use' && typeof b.name === 'string') {
109
210
  flush();
110
- push({ role: 'tool', tool: b.name, ...summarizeToolUse(b.name, b.input, worktree) });
211
+ push({ role: 'tool', tool: b.name, toolUseId: str(b.id), ...summarizeToolUse(b.name, b.input, worktree) });
111
212
  }
112
- else if (b.type === 'tool_result' && b.is_error) {
113
- // Successful results are noise on a phone; surface only failures.
213
+ else if (b.type === 'tool_result') {
214
+ // A result is written to a later row than the call it answers — anything slower than
215
+ // the 1s poll lands a tick or more behind it — so it travels as its own entry naming
216
+ // that call, and the phone folds the two together (web/src/lib/transcript-merge.ts).
217
+ // Only a failure repeats the output as `text`, which is what an unpaired one renders
218
+ // as. Repeating it for a success would send the biggest thing here twice: on the
219
+ // largest chat on this Mac that second copy was 569 kB of a 2.0 MB transcript.
114
220
  flush();
115
- push({ role: 'tool', error: true, text: resultText(b.content) || '(tool error)' });
221
+ const read = resultOutput(b.content, worktree);
222
+ // Bytes stay behind: a screenshot is ~100 kB of base64 in this row, and the phone
223
+ // fetches one only when the step it belongs to is opened (routes ▸ toolImage).
224
+ const images = Array.from({ length: read.images }, () => `${row.rowid}.${imageIndex++}`);
225
+ const output = read.text || (b.is_error ? '(tool error)' : '');
226
+ if (!output && !images.length)
227
+ continue;
228
+ const result = {
229
+ role: 'tool',
230
+ text: b.is_error ? output : '',
231
+ output,
232
+ toolUseId: str(b.tool_use_id)
233
+ };
234
+ if (read.diff)
235
+ result.diff = true;
236
+ if (images.length)
237
+ result.images = images;
238
+ if (b.is_error)
239
+ result.error = true;
240
+ push(result);
116
241
  }
117
242
  }
118
243
  flush();
@@ -169,6 +294,13 @@ export function renderTranscript(entries, format) {
169
294
  out.push(`[${parts.join(', ')} elided]`);
170
295
  };
171
296
  for (const e of entries) {
297
+ // A successful result is the output of the call printed just above it, and output is
298
+ // the biggest half of a chat (src/search.ts) as well as the least re-readable. So a
299
+ // render carries the call and leaves the file dumps behind — this is not an elision
300
+ // of a tool *call*, so it is not counted as one. A failure still prints: one line,
301
+ // and it is often why the answer changed course.
302
+ if (isToolResult(e) && !e.error)
303
+ continue;
172
304
  if (e.role === 'thinking' && !format.thinking) {
173
305
  pending.thinking++;
174
306
  elided.thinking++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.58.2",
3
+ "version": "1.59.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.",