conductor-remote 1.81.0 → 1.83.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,430 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ function object(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
7
+ }
8
+ function text(value) {
9
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
10
+ }
11
+ function number(value) {
12
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
13
+ }
14
+ function percent(value) {
15
+ const parsed = number(value);
16
+ return parsed === null ? null : Math.max(0, Math.min(100, parsed));
17
+ }
18
+ function timestamp(value) {
19
+ if (typeof value === 'number' && Number.isFinite(value)) {
20
+ // Codex reports seconds; tolerate milliseconds if the protocol changes.
21
+ return value < 10_000_000_000 ? value * 1000 : value;
22
+ }
23
+ if (typeof value !== 'string')
24
+ return null;
25
+ const parsed = Date.parse(value);
26
+ return Number.isFinite(parsed) ? parsed : null;
27
+ }
28
+ function codexWindowLabel(duration, slot) {
29
+ if (duration === 300)
30
+ return '5-hour limit';
31
+ if (duration === 1_440)
32
+ return 'Daily limit';
33
+ if (duration === 10_080)
34
+ return 'Weekly limit';
35
+ if (duration && duration % 1_440 === 0)
36
+ return `${duration / 1_440}-day limit`;
37
+ if (duration && duration % 60 === 0)
38
+ return `${duration / 60}-hour limit`;
39
+ return slot === 'primary' ? 'Primary limit' : 'Secondary limit';
40
+ }
41
+ function codexWindow(bucketId, slot, raw) {
42
+ const value = object(raw);
43
+ const usedPercent = percent(value?.usedPercent);
44
+ if (!value || usedPercent === null)
45
+ return null;
46
+ const duration = number(value.windowDurationMins);
47
+ return {
48
+ id: `${bucketId}:${slot}`,
49
+ label: codexWindowLabel(duration, slot),
50
+ usedPercent,
51
+ resetsAt: timestamp(value.resetsAt),
52
+ windowDurationMins: duration
53
+ };
54
+ }
55
+ /** Reduce Codex's app-server response to the stable, provider-neutral wire shape. */
56
+ export function parseCodexPlanUsage(raw) {
57
+ const envelope = object(raw);
58
+ const payload = object(envelope?.result) ?? envelope;
59
+ const legacy = object(payload?.rateLimits);
60
+ const byLimit = object(payload?.rateLimitsByLimitId);
61
+ const entries = byLimit && Object.keys(byLimit).length ? Object.entries(byLimit) : legacy ? [['codex', legacy]] : [];
62
+ const buckets = [];
63
+ let plan = null;
64
+ for (const [key, candidate] of entries) {
65
+ const snapshot = object(candidate);
66
+ if (!snapshot)
67
+ continue;
68
+ plan ??= text(snapshot.planType);
69
+ const id = text(snapshot.limitId) ?? key;
70
+ const windows = [
71
+ codexWindow(id, 'primary', snapshot.primary),
72
+ codexWindow(id, 'secondary', snapshot.secondary)
73
+ ].filter((window) => window !== null);
74
+ if (!windows.length)
75
+ continue;
76
+ buckets.push({ id, label: text(snapshot.limitName) ?? (id === 'codex' ? 'Codex' : id), windows });
77
+ }
78
+ if (!buckets.length) {
79
+ return {
80
+ provider: 'codex',
81
+ label: 'Codex',
82
+ status: 'unavailable',
83
+ plan,
84
+ buckets: [],
85
+ message: 'Codex returned no rolling plan limits for this account.'
86
+ };
87
+ }
88
+ buckets.sort((a, b) => Number(b.id === 'codex') - Number(a.id === 'codex') || a.label.localeCompare(b.label));
89
+ return { provider: 'codex', label: 'Codex', status: 'available', plan, buckets };
90
+ }
91
+ function claudeWindowLabel(limit) {
92
+ const kind = text(limit.kind);
93
+ const model = text(object(object(limit.scope)?.model)?.display_name);
94
+ if (kind === 'session')
95
+ return 'Current session';
96
+ if (kind === 'weekly_all')
97
+ return 'Current week';
98
+ if (kind === 'weekly_scoped' && model)
99
+ return `Current week (${model})`;
100
+ if (model)
101
+ return model;
102
+ return kind?.replaceAll('_', ' ') ?? 'Plan limit';
103
+ }
104
+ function claudeWindow(id, label, raw, active) {
105
+ const value = object(raw);
106
+ const usedPercent = percent(value?.utilization ?? value?.percent);
107
+ if (!value || usedPercent === null)
108
+ return null;
109
+ return {
110
+ id,
111
+ label,
112
+ usedPercent,
113
+ resetsAt: timestamp(value.resets_at),
114
+ ...(active === undefined ? {} : { active })
115
+ };
116
+ }
117
+ /** Reduce Claude's experimental `get_usage` control response; tolerate its older named-window shape. */
118
+ export function parseClaudePlanUsage(raw) {
119
+ const envelope = object(raw);
120
+ const control = object(envelope?.response);
121
+ const payload = object(control?.response) ?? envelope;
122
+ const plan = text(payload?.subscription_type);
123
+ if (payload?.rate_limits_available === false) {
124
+ return {
125
+ provider: 'claude',
126
+ label: 'Claude Code',
127
+ status: 'unavailable',
128
+ plan,
129
+ buckets: [],
130
+ message: 'Plan limits are not available for API-key or third-party-provider sessions.'
131
+ };
132
+ }
133
+ const rateLimits = object(payload?.rate_limits);
134
+ const windows = [];
135
+ const limits = Array.isArray(rateLimits?.limits) ? rateLimits.limits : [];
136
+ for (const [index, candidate] of limits.entries()) {
137
+ const limit = object(candidate);
138
+ if (!limit)
139
+ continue;
140
+ const kind = text(limit.kind);
141
+ // These are the rolling plan allowances. Spend/credit records have a different
142
+ // unit and belong in a future money-shaped control rather than a percentage bar.
143
+ if (!kind || !['session', 'weekly_all', 'weekly_scoped'].includes(kind))
144
+ continue;
145
+ const model = text(object(object(limit.scope)?.model)?.display_name);
146
+ const parsed = claudeWindow(`claude:${kind}:${model ?? index}`, claudeWindowLabel(limit), limit, limit.is_active === true);
147
+ if (parsed)
148
+ windows.push(parsed);
149
+ }
150
+ // Claude 2.1 first exposed the same data as named fields. Keep this fallback so an
151
+ // app update that removes the additive `limits` array does not blank the whole card.
152
+ if (!windows.length && rateLimits) {
153
+ const named = [
154
+ ['five_hour', 'Current session'],
155
+ ['seven_day', 'Current week'],
156
+ ['seven_day_opus', 'Current week (Opus)'],
157
+ ['seven_day_sonnet', 'Current week (Sonnet)']
158
+ ];
159
+ for (const [key, label] of named) {
160
+ const parsed = claudeWindow(`claude:${key}`, label, rateLimits[key]);
161
+ if (parsed)
162
+ windows.push(parsed);
163
+ }
164
+ const scoped = Array.isArray(rateLimits.model_scoped) ? rateLimits.model_scoped : [];
165
+ for (const [index, candidate] of scoped.entries()) {
166
+ const value = object(candidate);
167
+ const model = text(value?.display_name);
168
+ const parsed = claudeWindow(`claude:model:${model ?? index}:${index}`, `Current week (${model ?? 'model'})`, value);
169
+ if (parsed)
170
+ windows.push(parsed);
171
+ }
172
+ }
173
+ if (!windows.length) {
174
+ return {
175
+ provider: 'claude',
176
+ label: 'Claude Code',
177
+ status: 'unavailable',
178
+ plan,
179
+ buckets: [],
180
+ message: 'Claude Code returned no rolling plan limits for this account.'
181
+ };
182
+ }
183
+ return {
184
+ provider: 'claude',
185
+ label: 'Claude Code',
186
+ status: 'available',
187
+ plan,
188
+ buckets: [{ id: 'claude', label: 'Claude Code', windows }]
189
+ };
190
+ }
191
+ const BUNDLED_BINARIES = path.join(os.homedir(), 'Library', 'Application Support', 'com.conductor.app', 'agent-binaries');
192
+ /** Prefer the CLI Conductor runs, falling back to the user's PATH for older app installs. */
193
+ function agentBinary(provider) {
194
+ const root = path.join(BUNDLED_BINARIES, provider);
195
+ try {
196
+ const versions = fs
197
+ .readdirSync(root, { withFileTypes: true })
198
+ .filter(entry => entry.isDirectory())
199
+ .map(entry => entry.name)
200
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
201
+ for (const version of versions) {
202
+ const candidate = path.join(root, version, provider);
203
+ try {
204
+ fs.accessSync(candidate, fs.constants.X_OK);
205
+ return candidate;
206
+ }
207
+ catch {
208
+ // A half-downloaded version is not a CLI; try the next one.
209
+ }
210
+ }
211
+ }
212
+ catch {
213
+ // Conductor did not bundle this harness yet; spawn can still resolve the user's CLI.
214
+ }
215
+ return provider;
216
+ }
217
+ const MAX_CLI_OUTPUT = 4 * 1024 * 1024;
218
+ function failureMessage(name, stderr, code) {
219
+ const detail = stderr.trim().slice(-2_000);
220
+ return new Error(`${name} exited before returning usage${code === null ? '' : ` (${code})`}${detail ? `: ${detail}` : ''}`);
221
+ }
222
+ function codexResponse(binary = agentBinary('codex')) {
223
+ return new Promise((resolve, reject) => {
224
+ const child = spawn(binary, ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
225
+ let stdout = '';
226
+ let stderr = '';
227
+ let requested = false;
228
+ let settled = false;
229
+ const finish = (error, result) => {
230
+ if (settled)
231
+ return;
232
+ settled = true;
233
+ clearTimeout(timer);
234
+ child.kill();
235
+ if (error)
236
+ reject(error);
237
+ else
238
+ resolve(result);
239
+ };
240
+ const timer = setTimeout(() => finish(new Error('Codex plan-usage read timed out')), 8_000);
241
+ child.stdout.setEncoding('utf8');
242
+ child.stderr.setEncoding('utf8');
243
+ child.stderr.on('data', chunk => {
244
+ stderr = (stderr + chunk).slice(-MAX_CLI_OUTPUT);
245
+ });
246
+ child.stdout.on('data', chunk => {
247
+ stdout += chunk;
248
+ if (stdout.length > MAX_CLI_OUTPUT)
249
+ return finish(new Error('Codex plan-usage response was too large'));
250
+ let newline = stdout.indexOf('\n');
251
+ while (newline >= 0) {
252
+ const line = stdout.slice(0, newline).trim();
253
+ stdout = stdout.slice(newline + 1);
254
+ newline = stdout.indexOf('\n');
255
+ if (!line)
256
+ continue;
257
+ let message;
258
+ try {
259
+ message = JSON.parse(line);
260
+ }
261
+ catch {
262
+ continue;
263
+ }
264
+ if (message.id === 1 && !requested) {
265
+ requested = true;
266
+ child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
267
+ child.stdin.write(`${JSON.stringify({ id: 2, method: 'account/rateLimits/read', params: null })}\n`);
268
+ }
269
+ else if (message.id === 2) {
270
+ if (message.error)
271
+ return finish(new Error('Codex rejected the plan-usage request'));
272
+ return finish(null, message);
273
+ }
274
+ }
275
+ });
276
+ child.on('error', error => finish(error));
277
+ child.on('close', code => finish(failureMessage('Codex', stderr, code)));
278
+ child.stdin.on('error', error => finish(error));
279
+ child.stdin.write(`${JSON.stringify({
280
+ id: 1,
281
+ method: 'initialize',
282
+ params: { clientInfo: { name: 'conductor-remote', version: '1' } }
283
+ })}\n`);
284
+ });
285
+ }
286
+ function claudeResponse(binary = agentBinary('claude')) {
287
+ return new Promise((resolve, reject) => {
288
+ const child = spawn(binary, [
289
+ '-p',
290
+ '--input-format',
291
+ 'stream-json',
292
+ '--output-format',
293
+ 'stream-json',
294
+ '--verbose',
295
+ '--no-session-persistence',
296
+ '--safe-mode'
297
+ ], { stdio: ['pipe', 'pipe', 'pipe'] });
298
+ let stdout = '';
299
+ let stderr = '';
300
+ let settled = false;
301
+ const finish = (error, result) => {
302
+ if (settled)
303
+ return;
304
+ settled = true;
305
+ clearTimeout(timer);
306
+ child.kill();
307
+ if (error)
308
+ reject(error);
309
+ else
310
+ resolve(result);
311
+ };
312
+ const timer = setTimeout(() => finish(new Error('Claude plan-usage read timed out')), 10_000);
313
+ child.stdout.setEncoding('utf8');
314
+ child.stderr.setEncoding('utf8');
315
+ child.stderr.on('data', chunk => {
316
+ stderr = (stderr + chunk).slice(-MAX_CLI_OUTPUT);
317
+ });
318
+ child.stdout.on('data', chunk => {
319
+ stdout += chunk;
320
+ if (stdout.length > MAX_CLI_OUTPUT)
321
+ return finish(new Error('Claude plan-usage response was too large'));
322
+ let newline = stdout.indexOf('\n');
323
+ while (newline >= 0) {
324
+ const line = stdout.slice(0, newline).trim();
325
+ stdout = stdout.slice(newline + 1);
326
+ newline = stdout.indexOf('\n');
327
+ if (!line)
328
+ continue;
329
+ let message;
330
+ try {
331
+ message = JSON.parse(line);
332
+ }
333
+ catch {
334
+ continue;
335
+ }
336
+ const response = object(message.response);
337
+ if (message.type !== 'control_response' || response?.request_id !== 'plan-usage')
338
+ continue;
339
+ if (response.subtype !== 'success')
340
+ return finish(new Error('Claude rejected the structured plan-usage request'));
341
+ return finish(null, message);
342
+ }
343
+ });
344
+ child.on('error', error => finish(error));
345
+ child.on('close', code => finish(failureMessage('Claude', stderr, code)));
346
+ child.stdin.on('error', error => finish(error));
347
+ child.stdin.end(`${JSON.stringify({
348
+ type: 'control_request',
349
+ request_id: 'plan-usage',
350
+ request: { subtype: 'get_usage' }
351
+ })}\n`);
352
+ });
353
+ }
354
+ function unavailable(provider, label, message) {
355
+ return { provider, label, status: 'unavailable', plan: null, buckets: [], message };
356
+ }
357
+ function failed(provider, label, error) {
358
+ const detail = error instanceof Error ? error.message : String(error);
359
+ console.warn(`[relay] ${label} plan usage failed: ${detail}`);
360
+ const missing = error?.code === 'ENOENT';
361
+ return {
362
+ provider,
363
+ label,
364
+ status: missing ? 'unavailable' : 'error',
365
+ plan: null,
366
+ buckets: [],
367
+ message: missing ? `${label} is not installed on this Mac.` : `Could not read plan usage from ${label}.`
368
+ };
369
+ }
370
+ export async function readCodexPlanUsage() {
371
+ try {
372
+ return parseCodexPlanUsage(await codexResponse());
373
+ }
374
+ catch (error) {
375
+ return failed('codex', 'Codex', error);
376
+ }
377
+ }
378
+ export async function readClaudePlanUsage() {
379
+ try {
380
+ return parseClaudePlanUsage(await claudeResponse());
381
+ }
382
+ catch (error) {
383
+ return failed('claude', 'Claude Code', error);
384
+ }
385
+ }
386
+ const DEFAULT_READERS = [
387
+ { provider: 'claude', label: 'Claude Code', read: readClaudePlanUsage },
388
+ { provider: 'codex', label: 'Codex', read: readCodexPlanUsage }
389
+ ];
390
+ const UNSUPPORTED = [
391
+ unavailable('cursor', 'Cursor Agent', 'Cursor Agent does not expose plan limits through its CLI.'),
392
+ unavailable('opencode', 'OpenCode', 'OpenCode reports local token and cost totals, not provider plan limits.')
393
+ ];
394
+ /** Coalesced provider reads. `/api/usage` may be opened from several phones at once. */
395
+ export class PlanUsageService {
396
+ readers;
397
+ cacheMs;
398
+ now;
399
+ cached = null;
400
+ inFlight = null;
401
+ constructor(options = {}) {
402
+ this.readers = options.readers ?? DEFAULT_READERS;
403
+ this.cacheMs = options.cacheMs ?? 60_000;
404
+ this.now = options.now ?? Date.now;
405
+ }
406
+ read(force = false) {
407
+ if (!force && this.cached && this.now() - this.cached.fetchedAt < this.cacheMs)
408
+ return Promise.resolve(this.cached);
409
+ if (this.inFlight)
410
+ return this.inFlight;
411
+ const pending = Promise.all(this.readers.map(async (reader) => {
412
+ try {
413
+ return await reader.read();
414
+ }
415
+ catch (error) {
416
+ return failed(reader.provider, reader.label, error);
417
+ }
418
+ })).then(providers => {
419
+ const snapshot = { providers: [...providers, ...UNSUPPORTED], fetchedAt: this.now() };
420
+ this.cached = snapshot;
421
+ return snapshot;
422
+ });
423
+ this.inFlight = pending;
424
+ void pending.finally(() => {
425
+ if (this.inFlight === pending)
426
+ this.inFlight = null;
427
+ });
428
+ return pending;
429
+ }
430
+ }
@@ -46,6 +46,12 @@ export const routes = {
46
46
  repos: flat('GET', '/api/repos'),
47
47
  /** Picker labels the relay has previously read from Conductor, grouped by harness. */
48
48
  modelCatalog: flat('GET', '/api/models'),
49
+ /** Provider-specific defaults from Conductor's user settings TOML. */
50
+ modelDefaults: flat('GET', '/api/models/defaults'),
51
+ /** Update one or both provider defaults in Conductor's user settings TOML. */
52
+ updateModelDefaults: flat('PATCH', '/api/models/defaults'),
53
+ /** Rolling subscription limits, read without a model request from each provider CLI. */
54
+ planUsage: flat('GET', '/api/usage'),
49
55
  repoIcon: param('GET', '/api/repos/:repo/icon'),
50
56
  /** A temporary image emitted in a chat message, fetched with the phone's auth header. */
51
57
  localImage: param('GET', '/api/local-images/:path'),
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import zlib from 'node:zlib';
7
7
  import { attachmentPrompt, writeAttachment } from "./attachments.js";
8
8
  import { startAutoUpdate, updateStatus } from "./autoupdate.js";
9
+ import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "./conductor-settings.js";
9
10
  import { loadConfig, stateDir } from "./config.js";
10
11
  import { ConductorDb } from "./db.js";
11
12
  import { DevServerController } from "./dev-server.js";
@@ -20,6 +21,7 @@ import { ModelCache } from "./model-cache.js";
20
21
  import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
21
22
  import { chatRoute, noteViewing, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
22
23
  import { ParkedPromptQueue } from "./parked.js";
24
+ import { PlanUsageService } from "./plan-usage.js";
23
25
  import { attachPrStatus } from "./pr.js";
24
26
  import { readPrefs, writePrefs } from "./prefs.js";
25
27
  import { Reads } from "./reads.js";
@@ -30,7 +32,7 @@ import { readSettings, writeSettings } from "./settings.js";
30
32
  import { VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
31
33
  import { discardStagedAttachment, materializeStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
32
34
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
33
- import { renderTranscript, transcriptThrough } from "./transcript.js";
35
+ import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
34
36
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
35
37
  import { archiveWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
36
38
  // Before anything that logs: from here on every console line is also kept in memory for
@@ -46,6 +48,7 @@ const STAGED_ATTACHMENTS_DIR = path.join(stateDir(), 'attachment-staging');
46
48
  // relay state alongside the prompt queues. This lets a brand-new workspace choose
47
49
  // from a list before Conductor has created its first chat.
48
50
  const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
51
+ const planUsage = new PlanUsageService();
49
52
  // Full-text index over the chat prose, in the relay's own sidecar DB — never in
50
53
  // Conductor's (see src/search.ts). It backfills in the background and is disposable:
51
54
  // deleting the file rebuilds it on the next start.
@@ -880,6 +883,34 @@ const server = http.createServer(async (req, res) => {
880
883
  if (isRoute(routes.modelCatalog, req.method, pathname)) {
881
884
  return json(req, res, 200, { groups: modelCache.list(), defaultModel: modelCache.defaultModel() });
882
885
  }
886
+ // GET/PATCH /api/models/defaults — the live user-wide effort defaults.
887
+ // These are file-backed settings, not the stale rows conductor.db still carries.
888
+ if (isRoute(routes.modelDefaults, req.method, pathname)) {
889
+ return json(req, res, 200, { defaultEfforts: readDefaultEfforts() });
890
+ }
891
+ if (isRoute(routes.updateModelDefaults, req.method, pathname)) {
892
+ const body = JSON.parse((await readBody(req)) || '{}');
893
+ const patch = {};
894
+ if (body.claude !== undefined) {
895
+ if (!isDefaultEffortLevel(body.claude))
896
+ return json(req, res, 400, { error: 'unknown Claude effort level' });
897
+ patch.claude = body.claude;
898
+ }
899
+ if (body.codex !== undefined) {
900
+ if (!isDefaultEffortLevel(body.codex))
901
+ return json(req, res, 400, { error: 'unknown Codex effort level' });
902
+ patch.codex = body.codex;
903
+ }
904
+ if (Object.keys(patch).length === 0)
905
+ return json(req, res, 400, { error: 'nothing to change' });
906
+ return json(req, res, 200, { defaultEfforts: writeDefaultEfforts(patch) });
907
+ }
908
+ // GET /api/usage — structured subscription limits from the CLIs Conductor
909
+ // itself bundles. Both reads are prompt-free and cached; `refresh=1` is the
910
+ // explicit user action in the sheet, never a background poll.
911
+ if (isRoute(routes.planUsage, req.method, pathname)) {
912
+ return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
913
+ }
883
914
  // GET /api/settings — relay preferences plus what the phone needs to edit them:
884
915
  // the SSIDs this Mac already holds credentials for, so the picker offers a choice
885
916
  // instead of asking someone to type a network name from memory on a phone keyboard.
@@ -1619,7 +1650,8 @@ const server = http.createServer(async (req, res) => {
1619
1650
  });
1620
1651
  return json(req, res, answer.status, answer.body);
1621
1652
  }
1622
- // POST /api/sessions/:id/split { prompt?, includeThinking?, includeTools?, throughRowid? }
1653
+ // POST /api/sessions/:id/split
1654
+ // { prompt?, includeThinking?, includeTools?, throughRowid?, onlyRowid? }
1623
1655
  //
1624
1656
  // Conductor's own "Fork to new tab" resumes the agent's real session. This copies
1625
1657
  // the conversation instead, as a Conductor attachment, which is the cut that
@@ -1653,14 +1685,25 @@ const server = http.createServer(async (req, res) => {
1653
1685
  const format = { thinking: body.includeThinking !== false, tools: body.includeTools === true };
1654
1686
  const { entries } = reads.getMessages(sessionId);
1655
1687
  const through = body.throughRowid;
1688
+ const only = body.onlyRowid;
1656
1689
  if (through !== undefined && (!Number.isSafeInteger(through) || through < 1)) {
1657
1690
  return json(req, res, 400, { error: 'throughRowid must be a positive integer' });
1658
1691
  }
1659
- const cut = through === undefined ? { entries, later: 0 } : transcriptThrough(entries, through);
1692
+ if (only !== undefined && (!Number.isSafeInteger(only) || only < 1)) {
1693
+ return json(req, res, 400, { error: 'onlyRowid must be a positive integer' });
1694
+ }
1695
+ if (through !== undefined && only !== undefined) {
1696
+ return json(req, res, 400, { error: 'throughRowid and onlyRowid cannot be combined' });
1697
+ }
1698
+ const cut = only !== undefined
1699
+ ? transcriptMessage(entries, only)
1700
+ : through === undefined
1701
+ ? { entries, earlier: 0, later: 0 }
1702
+ : transcriptThrough(entries, through);
1660
1703
  if (!cut)
1661
1704
  return json(req, res, 409, { error: 'that message is not in this chat' });
1662
1705
  const rendered = renderTranscript(cut.entries, format);
1663
- const elided = { ...rendered.elided, later: cut.later };
1706
+ const elided = { ...rendered.elided, earlier: 'earlier' in cut ? cut.earlier : 0, later: cut.later };
1664
1707
  if (!rendered.kept)
1665
1708
  return json(req, res, 409, { error: 'that chat has nothing to copy yet' });
1666
1709
  // Conductor's own name for a copied transcript, so the chip reads the same as one
@@ -1669,11 +1712,13 @@ const server = http.createServer(async (req, res) => {
1669
1712
  const title = source.title?.trim() || 'chat';
1670
1713
  const carried = [`thinking ${format.thinking ? 'included' : 'omitted'}`];
1671
1714
  carried.push(`tool calls ${format.tools ? 'included' : 'omitted'}`);
1672
- const stops = cut.later
1673
- ? [
1674
- `The copy stops partway through: ${cut.later} later ${cut.later === 1 ? 'entry is' : 'entries are'} not in it.`
1675
- ]
1676
- : [];
1715
+ const stops = only !== undefined
1716
+ ? ['The copy contains only the selected source message; all earlier and later messages are omitted.']
1717
+ : cut.later
1718
+ ? [
1719
+ `The copy stops partway through: ${cut.later} later ${cut.later === 1 ? 'entry is' : 'entries are'} not in it.`
1720
+ ]
1721
+ : [];
1677
1722
  const header = [
1678
1723
  `# Transcript of ${title}`,
1679
1724
  '',
@@ -278,6 +278,21 @@ export function transcriptThrough(entries, rowid) {
278
278
  const kept = entries.filter(entry => entry.rowid <= rowid);
279
279
  return { entries: kept, later: entries.length - kept.length };
280
280
  }
281
+ /**
282
+ * Keep one source message and nothing around it.
283
+ *
284
+ * One `session_messages` row can produce several transcript entries — prose split by
285
+ * reasoning or tool calls — so selecting by rowid keeps them together. Treating the
286
+ * last rendered bubble as the message would silently lose the other prose fragments.
287
+ */
288
+ export function transcriptMessage(entries, rowid) {
289
+ const first = entries.findIndex(entry => entry.rowid === rowid);
290
+ if (first < 0)
291
+ return null;
292
+ const kept = entries.filter(entry => entry.rowid === rowid);
293
+ const last = entries.findLastIndex(entry => entry.rowid === rowid);
294
+ return { entries: kept, earlier: first, later: entries.length - last - 1 };
295
+ }
281
296
  /**
282
297
  * A chat as markdown, in Conductor's own transcript layout.
283
298
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.81.0",
3
+ "version": "1.83.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.",