conductor-remote 1.113.0 → 1.115.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
@@ -25,8 +25,8 @@
25
25
  <title>Conductor Remote</title>
26
26
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
27
27
  <script src="/self-heal.js"></script>
28
- <script type="module" crossorigin src="/assets/index-DDm_FLf-.js"></script>
29
- <link rel="stylesheet" crossorigin href="/assets/index-MmdttBBU.css">
28
+ <script type="module" crossorigin src="/assets/index-Dkiq148q.js"></script>
29
+ <link rel="stylesheet" crossorigin href="/assets/index-DNLdIfZ2.css">
30
30
  <link rel="manifest" href="/manifest.webmanifest"></head>
31
31
  <body>
32
32
  <div id="root"></div>
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 l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const t=e=>i(e,l),a={module:{uri:l},exports:o,require:t};s[l]=Promise.all(n.map(e=>a[e]||t(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],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:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"ea31ef7e6fc1c359645d9ce940db4e03"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-MmdttBBU.css",revision:null},{url:"assets/index-DDm_FLf-.js",revision:null},{url:"assets/PierrePatch-awVutyVW.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
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 l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const a=e=>i(e,l),t={module:{uri:l},exports:o,require:a};s[l]=Promise.all(n.map(e=>t[e]||a(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],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:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"c0864e9c2073eea6a60e6105a3f60865"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-Dkiq148q.js",revision:null},{url:"assets/index-DNLdIfZ2.css",revision:null},{url:"assets/PierrePatch-DGi9B1-s.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
@@ -11,7 +11,7 @@ const BYTES_PER_TOKEN = 4;
11
11
  export function estimateTextTokens(text) {
12
12
  return text ? Math.ceil(Buffer.byteLength(text, 'utf8') / BYTES_PER_TOKEN) : 0;
13
13
  }
14
- function parsedFrame(row) {
14
+ export function parseContextFrame(row) {
15
15
  const content = row.content ?? '';
16
16
  if (!content.startsWith('{'))
17
17
  return null;
@@ -42,17 +42,42 @@ function isCompactBoundary(frame) {
42
42
  * ordinary text. Providers account for those specially; treating base64 as prose can
43
43
  * turn one screenshot into hundreds of thousands of imaginary tool tokens.
44
44
  */
45
- function blockBytes(block) {
46
- const json = JSON.stringify(block, function (key, value) {
45
+ export function contextBlockBytes(block) {
46
+ function visibleValue(key, value) {
47
47
  if (key === 'signature' || key === 'encrypted_content')
48
48
  return undefined;
49
49
  if (typeof value === 'string' && /^data:[^;,]+;base64,/i.test(value))
50
50
  return '[binary data]';
51
- if (key === 'data' && typeof value === 'string' && this.type === 'base64') {
52
- return '[binary image]';
51
+ if (key === 'data' && typeof value === 'string' && ['base64', 'image', 'audio'].includes(String(this.type))) {
52
+ return '[binary data]';
53
+ }
54
+ if (key === 'blob' && typeof value === 'string' && typeof this.mimeType === 'string')
55
+ return '[binary data]';
56
+ // Codex often saves an MCP result as serialized JSON inside the tool_result
57
+ // string. Its image.data is still binary, even though the outer frame looks
58
+ // textual. Preserve ordinary JSON formatting unless a payload was stripped.
59
+ if (key === 'content' &&
60
+ this.type === 'tool_result' &&
61
+ typeof value === 'string' &&
62
+ (/"(?:data|blob|signature|encrypted_content)"\s*:/.test(value) || /data:[^;,]+;base64,/i.test(value))) {
63
+ try {
64
+ let changed = false;
65
+ const cleaned = JSON.stringify(JSON.parse(value), function (nestedKey, nestedValue) {
66
+ const visible = visibleValue.call(this, nestedKey, nestedValue);
67
+ if (visible !== nestedValue)
68
+ changed = true;
69
+ return visible;
70
+ });
71
+ if (changed)
72
+ return cleaned;
73
+ }
74
+ catch {
75
+ // Ordinary tool text need not be JSON.
76
+ }
53
77
  }
54
78
  return value;
55
- });
79
+ }
80
+ const json = JSON.stringify(block, visibleValue);
56
81
  return json ? Buffer.byteLength(json, 'utf8') : 0;
57
82
  }
58
83
  function fittedCategories(bytes, totalTokens) {
@@ -90,7 +115,7 @@ function fittedCategories(bytes, totalTokens) {
90
115
  */
91
116
  export function estimateContextCategories(rows, totalTokens) {
92
117
  const total = Number.isFinite(totalTokens) ? Math.max(0, Math.round(totalTokens)) : 0;
93
- const parsed = rows.map(row => ({ ...row, frame: parsedFrame(row) }));
118
+ const parsed = rows.map(row => ({ ...row, frame: parseContextFrame(row) }));
94
119
  // A Claude subagent's frames are copied into its parent chat. Its own result and
95
120
  // compaction markers describe the child's window, not the session meter beside this
96
121
  // tab, so only root frames may define the completed cut or reset the active history.
@@ -125,7 +150,7 @@ export function estimateContextCategories(rows, totalTokens) {
125
150
  if (!raw || typeof raw !== 'object')
126
151
  continue;
127
152
  const type = typeof raw.type === 'string' ? raw.type : '';
128
- const size = blockBytes(raw);
153
+ const size = contextBlockBytes(raw);
129
154
  if (type === 'tool_use' || type === 'tool_result')
130
155
  bytes.tools += size;
131
156
  else if (/thinking|reasoning/.test(type))
@@ -223,6 +223,8 @@ async function localState(worktree) {
223
223
  * a mention only when it matches a real file. The same list lets a reviewer browse
224
224
  * source beyond the changed set. Tracked plus untracked-not-ignored: an agent that just
225
225
  * wrote a file should appear in both places long before anything commits it.
226
+ * The root `.context/` directory is included even when ignored, so workspace notes
227
+ * and plans are browsable too. Other ignored paths keep their normal exclusions.
226
228
  *
227
229
  * Two things keep the payload small. Only previewable extensions ship, because
228
230
  * `/api/files` refuses everything else anyway, and 20,000 paths is the ceiling — a
@@ -232,7 +234,16 @@ async function localState(worktree) {
232
234
  export async function listSourceFiles(worktree) {
233
235
  let listing = '';
234
236
  try {
235
- listing = await git(worktree, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
237
+ listing = await git(worktree, [
238
+ 'ls-files',
239
+ '--cached',
240
+ '--others',
241
+ '--exclude-standard',
242
+ // Unignore the directory itself to let Git descend, then all its contents.
243
+ '--exclude=!/.context/',
244
+ '--exclude=!/.context/**',
245
+ '-z'
246
+ ]);
236
247
  }
237
248
  catch {
238
249
  return { files: [], truncated: false };
@@ -350,6 +350,12 @@ export class Reads {
350
350
  return toSessionRow(row, started === undefined ? [] : this.openBackgroundTasks(row.id, started));
351
351
  });
352
352
  }
353
+ listClosedSessions(workspaceId) {
354
+ return this.db.query(`SELECT id, title, model, agent_type, created_at, updated_at
355
+ FROM sessions
356
+ WHERE workspace_id = ? AND is_hidden = 1
357
+ ORDER BY REPLACE(REPLACE(updated_at, 'T', ' '), 'Z', '') DESC, created_at DESC, id ASC`, [workspaceId]);
358
+ }
353
359
  /** One delegated child, including its provider-specific open-task guard. */
354
360
  getSession(sessionId) {
355
361
  const rows = this.db.query(`SELECT s.id, s.status, s.title, s.model, s.permission_mode,
@@ -52,6 +52,8 @@ export const routes = {
52
52
  updateModelDefaults: flat('PATCH', '/api/models/defaults'),
53
53
  /** Rolling subscription limits, read without a model request from each provider CLI. */
54
54
  planUsage: flat('GET', '/api/usage'),
55
+ /** Estimated tool input/result tokens from recent saved chat activity. */
56
+ toolUsage: flat('GET', '/api/usage/tools'),
55
57
  /** Mint a short-lived OpenAI SIP URI for an authenticated native client. */
56
58
  voiceTicket: flat('POST', '/api/voice/ticket'),
57
59
  /** Negotiate one app-wide PWA orchestrator call through the relay. */
@@ -123,6 +125,7 @@ export const routes = {
123
125
  /** One workspace by id, archived included — what `/api/state` deliberately leaves out. */
124
126
  workspace: param('GET', '/api/workspaces/:workspaceId'),
125
127
  sessions: param('GET', '/api/workspaces/:workspaceId/sessions'),
128
+ closedSessions: param('GET', '/api/workspaces/:workspaceId/sessions/closed'),
126
129
  newChat: param('POST', '/api/workspaces/:workspaceId/sessions'),
127
130
  diff: param('GET', '/api/workspaces/:workspaceId/diff'),
128
131
  /** One complete changed-file patch, addressed by the `path` query parameter. */
@@ -152,6 +155,8 @@ export const routes = {
152
155
  stop: param('POST', '/api/sessions/:sessionId/stop'),
153
156
  /** Hide one chat tab through Conductor's own Close tab action (Command-W). */
154
157
  closeChat: param('DELETE', '/api/sessions/:sessionId'),
158
+ /** Restore the original chat by its workspace/session deep link. */
159
+ restoreChat: param('POST', '/api/sessions/:sessionId/restore'),
155
160
  sendPrompt: param('POST', '/api/sessions/:sessionId/prompt'),
156
161
  /** Write a phone-selected file into Conductor's attachment layout for this chat's workspace. */
157
162
  uploadAttachment: param('POST', '/api/sessions/:sessionId/attachments'),
@@ -41,6 +41,8 @@ import { readSettings, writeSettings } from "./settings.js";
41
41
  import { attachmentTokens, isOpenAIRealtimeVoice, isVoiceLanguage, modelLabel, OPENAI_REALTIME_VOICES, responseErrorMessage, scrubWorkflowSecrets, timestampMs, VIEWING_HEADER, withoutClientWindowEvidence, withoutWindowEvidence, workspaceTitle } from "./shared.js";
42
42
  import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
43
43
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
44
+ import { isToolUsageRange } from "./tool-usage.js";
45
+ import { ToolUsageService } from "./tool-usage-service.js";
44
46
  import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
45
47
  import { recoverExpiredUiLease } from "./ui-lease-watchdog.js";
46
48
  import { VoiceBriefBoard } from "./voice/brief.js";
@@ -58,7 +60,7 @@ import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks }
58
60
  import { WorkflowCoordinator, WorkflowCoordinatorError } from "./workflow-coordinator.js";
59
61
  import { parseConfirmUiStableRequest, parseStartWorkflowRequest, parseWorkflowAdoptRequest, parseWorkflowCompleteRequest, parseWorkflowDelegateRequest, parseWorkflowReplayRequest, parseWorkflowRetryRequest, WorkflowRequestError, workflowClientIsMcp } from "./workflow-http.js";
60
62
  import { WorkflowGuardError } from "./workflow-machine.js";
61
- import { archiveWorkspace, closeChat, configureSharedUiLeaseProvider, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, uiTurn, WORKSPACE_STATUS_LABELS, withGatedUiCommand, withUiPriority } from "./writes.js";
63
+ import { archiveWorkspace, closeChat, configureSharedUiLeaseProvider, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, restoreChat, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, uiTurn, WORKSPACE_STATUS_LABELS, withGatedUiCommand, withUiPriority } from "./writes.js";
62
64
  // Before anything that logs: from here on every console line is also kept in memory for
63
65
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
64
66
  installLogCapture();
@@ -92,6 +94,7 @@ function stagedAttachmentIdsInObjective(objective) {
92
94
  // from a list before Conductor has created its first chat.
93
95
  const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
94
96
  const planUsage = new PlanUsageService();
97
+ const toolUsage = new ToolUsageService(cfg.dbPath);
95
98
  const roleStore = new RoleStore(path.join(stateDir(), 'roles.json'));
96
99
  const orchestration = new OrchestrationDb(path.join(stateDir(), 'orchestration.db'), {
97
100
  processProbe: processIdentityAlive
@@ -2340,6 +2343,12 @@ const server = http.createServer(async (req, res) => {
2340
2343
  if (isRoute(routes.planUsage, req.method, pathname)) {
2341
2344
  return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
2342
2345
  }
2346
+ if (isRoute(routes.toolUsage, req.method, pathname)) {
2347
+ const range = url.searchParams.get('range') ?? '24h';
2348
+ if (!isToolUsageRange(range))
2349
+ return json(req, res, 400, { error: 'Choose 24h, 7d, or 30d for tool usage.' });
2350
+ return json(req, res, 200, await toolUsage.read(range, url.searchParams.get('refresh') === '1'));
2351
+ }
2343
2352
  if (isRoute(routes.roles, req.method, pathname)) {
2344
2353
  const stored = roleStore.read();
2345
2354
  return json(req, res, 200, {
@@ -3059,6 +3068,12 @@ const server = http.createServer(async (req, res) => {
3059
3068
  ...(Object.keys(sessionRoles).length ? { session_roles: sessionRoles } : {})
3060
3069
  });
3061
3070
  }
3071
+ const closedSessionsIn = routeParam(routes.closedSessions, req.method, pathname);
3072
+ if (closedSessionsIn) {
3073
+ if (!reads.getAnyWorkspace(closedSessionsIn))
3074
+ return json(req, res, 404, { error: 'workspace not found' });
3075
+ return json(req, res, 200, { sessions: reads.listClosedSessions(closedSessionsIn) });
3076
+ }
3062
3077
  // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
3063
3078
  const newChatIn = routeParam(routes.newChat, req.method, pathname);
3064
3079
  if (newChatIn) {
@@ -3422,6 +3437,23 @@ const server = http.createServer(async (req, res) => {
3422
3437
  session: reads.listSessions(ws.id).find(s => s.id === sessionId)
3423
3438
  });
3424
3439
  }
3440
+ const restoreChatId = routeParam(routes.restoreChat, req.method, pathname);
3441
+ if (restoreChatId) {
3442
+ const body = JSON.parse((await readBody(req)) || '{}');
3443
+ const ownerId = reads.sessionWorkspaceId(restoreChatId);
3444
+ if (!ownerId)
3445
+ return json(req, res, 404, { error: 'chat not found' });
3446
+ if (body.workspaceId && body.workspaceId !== ownerId) {
3447
+ return json(req, res, 409, { error: 'chat is not in that workspace' });
3448
+ }
3449
+ if (!reads.getWorkspace(ownerId)) {
3450
+ return json(req, res, 409, { error: 'Restore this workspace in Conductor before restoring its tabs.' });
3451
+ }
3452
+ const result = await restoreChat(ownerId, restoreChatId, () => reads.getSession(restoreChatId) !== null);
3453
+ if (!result.ok)
3454
+ return json(req, res, 502, result);
3455
+ return json(req, res, 200, { ...result, session: reads.getSession(restoreChatId) });
3456
+ }
3425
3457
  // DELETE /api/sessions/:id { workspaceId?, closeRunning? } — Conductor's
3426
3458
  // reversible Close tab action (Command-W). A running chat gets the same
3427
3459
  // explicit "Close anyway" gate as the desktop app.
@@ -0,0 +1,61 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ const CACHE_MS = 60_000;
3
+ const READ_TIMEOUT_MS = 60_000;
4
+ /**
5
+ * History can be large: keep SQLite and JSON parsing off the HTTP event loop.
6
+ * One disposable worker at a time reads a range; cached/in-flight reads are shared
7
+ * across phones. Nothing runs until Models requests it, and no index is persisted.
8
+ */
9
+ export class ToolUsageService {
10
+ dbPath;
11
+ cache = new Map();
12
+ pending = new Map();
13
+ queue = Promise.resolve();
14
+ constructor(dbPath) {
15
+ this.dbPath = dbPath;
16
+ }
17
+ read(range, refresh = false) {
18
+ const running = this.pending.get(range);
19
+ if (running)
20
+ return running;
21
+ const cached = this.cache.get(range);
22
+ if (!refresh && cached && Date.now() - cached.fetchedAt < CACHE_MS)
23
+ return Promise.resolve(cached);
24
+ const read = this.queue.then(() => this.scan(range));
25
+ this.pending.set(range, read);
26
+ this.queue = read.catch(() => { });
27
+ void read.then(data => {
28
+ this.cache.set(range, data);
29
+ this.pending.delete(range);
30
+ }, () => this.pending.delete(range));
31
+ return read;
32
+ }
33
+ scan(range) {
34
+ return new Promise((resolve, reject) => {
35
+ const module = import.meta.url.endsWith('.ts') ? './tool-usage-worker.ts' : './tool-usage-worker.js';
36
+ const worker = new Worker(new URL(module, import.meta.url), {
37
+ workerData: { dbPath: this.dbPath, range },
38
+ // Node's watch supervisor expands process flags that workers reject.
39
+ // These modules need only Node's built-in type stripping, not the
40
+ // parent's process-level V8, inspector, or watch configuration.
41
+ execArgv: ['--disable-warning=ExperimentalWarning']
42
+ });
43
+ let settled = false;
44
+ const finish = (error, data) => {
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ clearTimeout(timeout);
49
+ void worker.terminate();
50
+ if (error)
51
+ reject(error);
52
+ else if (data)
53
+ resolve(data);
54
+ };
55
+ const timeout = setTimeout(() => finish(new Error('Tool usage took too long to read. Try a shorter range.')), READ_TIMEOUT_MS);
56
+ worker.once('message', (data) => finish(undefined, data));
57
+ worker.once('error', error => finish(error));
58
+ worker.once('exit', () => finish(new Error('Could not read tool usage. Try again.')));
59
+ });
60
+ }
61
+ }
@@ -0,0 +1,6 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { ConductorDb } from "./db.js";
3
+ import { readToolUsage } from "./tool-usage.js";
4
+ const { dbPath, range } = workerData;
5
+ const db = new ConductorDb(dbPath);
6
+ parentPort?.postMessage(readToolUsage(db, range));
@@ -0,0 +1,109 @@
1
+ import { contextBlockBytes, parseContextFrame } from "./context-breakdown.js";
2
+ export function isToolUsageRange(value) {
3
+ return value === '24h' || value === '7d' || value === '30d';
4
+ }
5
+ /**
6
+ * Join calls and results within one chat, never across chats with reused call IDs.
7
+ * Older calls supply names only, so a result crossing the window's start still has
8
+ * an owner. Repeated snapshots of a call/result use the largest saved payload once.
9
+ * No prompts, arguments, or output text leave this accumulator.
10
+ */
11
+ export class ToolUsageAccumulator {
12
+ calls = new Map();
13
+ add(row) {
14
+ const frame = parseContextFrame(row);
15
+ if (!frame || frame.parent_tool_use_id || !Array.isArray(frame.message?.content))
16
+ return;
17
+ for (const [index, raw] of frame.message.content.entries()) {
18
+ if (!raw || typeof raw !== 'object')
19
+ continue;
20
+ const block = raw;
21
+ const isCall = block.type === 'tool_use';
22
+ if (!isCall && (block.type !== 'tool_result' || !row.in_range))
23
+ continue;
24
+ const rawId = isCall ? block.id : block.tool_use_id;
25
+ const id = typeof rawId === 'string' && rawId ? `id:${rawId}` : `row:${row.rowid}:${index}`;
26
+ const call = this.calls.get(id) ?? { name: null, inputBytes: 0, outputBytes: 0 };
27
+ if (isCall && typeof block.name === 'string' && block.name.trim())
28
+ call.name = block.name.trim();
29
+ if (row.in_range) {
30
+ const key = isCall ? 'inputBytes' : 'outputBytes';
31
+ call[key] = Math.max(call[key], contextBlockBytes(block));
32
+ }
33
+ this.calls.set(id, call);
34
+ }
35
+ }
36
+ tools() {
37
+ const tools = new Map();
38
+ for (const call of this.calls.values()) {
39
+ if (!call.inputBytes && !call.outputBytes)
40
+ continue;
41
+ const inputTokens = Math.ceil(call.inputBytes / 4);
42
+ const outputTokens = Math.ceil(call.outputBytes / 4);
43
+ mergeToolUsage(tools, {
44
+ name: call.name,
45
+ calls: 1,
46
+ inputTokens,
47
+ outputTokens,
48
+ totalTokens: inputTokens + outputTokens,
49
+ largestCallTokens: inputTokens + outputTokens
50
+ });
51
+ }
52
+ return [...tools.values()].sort((a, b) => b.totalTokens - a.totalTokens);
53
+ }
54
+ }
55
+ function mergeToolUsage(tools, row) {
56
+ const previous = tools.get(row.name);
57
+ if (!previous) {
58
+ tools.set(row.name, { ...row });
59
+ return;
60
+ }
61
+ previous.calls += row.calls;
62
+ previous.inputTokens += row.inputTokens;
63
+ previous.outputTokens += row.outputTokens;
64
+ previous.totalTokens += row.totalTokens;
65
+ previous.largestCallTokens = Math.max(previous.largestCallTokens, row.largestCallTokens);
66
+ }
67
+ /** On-demand worker read. Includes archived workspaces and hidden chats. */
68
+ export function readToolUsage(db, range, now = Date.now()) {
69
+ const days = range === '24h' ? 1 : range === '7d' ? 7 : 30;
70
+ const since = new Date(now - days * 86_400_000).toISOString();
71
+ const until = new Date(now).toISOString();
72
+ const sessions = db.query(`SELECT id, agent_type FROM sessions WHERE julianday(updated_at) >= julianday(?)`, [since]);
73
+ const providers = new Map();
74
+ for (const session of sessions) {
75
+ const accumulator = new ToolUsageAccumulator();
76
+ // Conductor has a session_id index, but no global message-time index. Scope
77
+ // each scan to a recently active chat and keep large historical results out of
78
+ // JS. Prior calls are retained only to name results that cross the lower bound.
79
+ // julianday handles Conductor's mixture of SQLite and ISO timestamp formats.
80
+ const rows = db.query(`SELECT rowid, role, content, julianday(created_at) >= julianday(?) AS in_range
81
+ FROM session_messages
82
+ WHERE session_id = ? AND julianday(created_at) <= julianday(?)
83
+ AND (instr(content, '"tool_use"') > 0
84
+ OR (julianday(created_at) >= julianday(?) AND instr(content, '"tool_result"') > 0))
85
+ ORDER BY rowid ASC`, [since, session.id, until, since]);
86
+ for (const row of rows)
87
+ accumulator.add(row);
88
+ const tools = accumulator.tools();
89
+ if (!tools.length)
90
+ continue;
91
+ const provider = session.agent_type === 'acp' ? 'opencode' : (session.agent_type ?? 'unknown');
92
+ const group = providers.get(provider) ?? { sessionCount: 0, tools: new Map() };
93
+ group.sessionCount++;
94
+ for (const tool of tools)
95
+ mergeToolUsage(group.tools, tool);
96
+ providers.set(provider, group);
97
+ }
98
+ return {
99
+ range,
100
+ since,
101
+ until,
102
+ fetchedAt: now,
103
+ providers: [...providers].map(([provider, group]) => ({
104
+ provider,
105
+ sessionCount: group.sessionCount,
106
+ tools: [...group.tools.values()].sort((a, b) => b.totalTokens - a.totalTokens)
107
+ }))
108
+ };
109
+ }
@@ -28,9 +28,28 @@ export function buildAcceptBody(input) {
28
28
  ]
29
29
  };
30
30
  }
31
- /** USD estimate for the model whose rates are pinned in the design and documentation. */
31
+ // USD per million tokens: https://developers.openai.com/api/docs/models/gpt-realtime-2.1
32
+ // Mini remains configurable: https://developers.openai.com/api/docs/models/gpt-realtime-2.1-mini
33
+ const REALTIME_RATES = new Map([
34
+ [
35
+ 'gpt-realtime-2.1',
36
+ {
37
+ text: { input: 4, cached: 0.4, output: 24 },
38
+ audio: { input: 32, cached: 0.4, output: 64 }
39
+ }
40
+ ],
41
+ [
42
+ 'gpt-realtime-2.1-mini',
43
+ {
44
+ text: { input: 0.6, cached: 0.06, output: 2.4 },
45
+ audio: { input: 10, cached: 0.3, output: 20 }
46
+ }
47
+ ]
48
+ ]);
49
+ /** USD estimate from the configured model's published text and audio rates. */
32
50
  export function estimateRealtimeCost(model, usage) {
33
- if (model !== 'gpt-realtime-2.1-mini')
51
+ const rates = REALTIME_RATES.get(model);
52
+ if (!rates)
34
53
  return null;
35
54
  const input = usage.input_token_details ?? {};
36
55
  const output = usage.output_token_details ?? {};
@@ -38,12 +57,12 @@ export function estimateRealtimeCost(model, usage) {
38
57
  const cachedAudio = input.cached_tokens_details?.audio_tokens ?? 0;
39
58
  const uncachedText = Math.max(0, (input.text_tokens ?? 0) - cachedText);
40
59
  const uncachedAudio = Math.max(0, (input.audio_tokens ?? 0) - cachedAudio);
41
- return ((uncachedText * 0.6 +
42
- cachedText * 0.06 +
43
- uncachedAudio * 10 +
44
- cachedAudio * 0.3 +
45
- (output.text_tokens ?? 0) * 2.4 +
46
- (output.audio_tokens ?? 0) * 20) /
60
+ return ((uncachedText * rates.text.input +
61
+ cachedText * rates.text.cached +
62
+ uncachedAudio * rates.audio.input +
63
+ cachedAudio * rates.audio.cached +
64
+ (output.text_tokens ?? 0) * rates.text.output +
65
+ (output.audio_tokens ?? 0) * rates.audio.output) /
47
66
  1_000_000);
48
67
  }
49
68
  function defaultSocket(url, headers) {
@@ -29,7 +29,7 @@ const EMPTY = {
29
29
  pin: null,
30
30
  projectId: null,
31
31
  publicBaseUrl: null,
32
- model: 'gpt-realtime-2.1-mini',
32
+ model: 'gpt-realtime-2.1',
33
33
  voice: 'marin',
34
34
  sipHost: 'sip.api.openai.com'
35
35
  };
@@ -168,10 +168,10 @@ function promptRole(role) {
168
168
  return role;
169
169
  }
170
170
  export class WorkflowCoordinator {
171
+ waking = new Map();
171
172
  db;
172
173
  relay;
173
174
  deps;
174
- waking = new Map();
175
175
  constructor(db, relay, deps) {
176
176
  this.db = db;
177
177
  this.relay = relay;
@@ -3,7 +3,7 @@ import { execFile } from 'node:child_process';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { isLockedError, modelPickerLabel } from "./shared.js";
6
+ import { isLockedError, MAC_LOCKED, modelPickerLabel } from "./shared.js";
7
7
  import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
8
8
  import { runGatedWorkflowCommand, WorkflowGatedCommandError } from "./workflow-effect-runner.js";
9
9
  const execDirect = promisify(execFile);
@@ -412,9 +412,9 @@ const CONDUCTOR_SCHEME = process.env.RELAY_CONDUCTOR_SCHEME || 'conductor';
412
412
  *
413
413
  * `session` is optional and names a chat by `sessions.id`, the same id the relay
414
414
  * already serves; omitted, Conductor opens whichever tab that workspace had.
415
- * A hidden chat (`sessions.is_hidden`) has no tab, and Conductor keeps the
416
- * workspace's current one rather than reporting anything, so the caller's own
417
- * tab assertion is still what catches it.
415
+ * Current Conductor builds also unhide a session named by this route, through
416
+ * their own session service. `restoreChat` uses that behavior and confirms the
417
+ * exact id became visible; older builds may ignore it, so opening is no receipt.
418
418
  *
419
419
  * The https form Conductor copies for sharing —
420
420
  * `https://app.conductor.build/workspace/<id>?session=<chat>` — reaches the same
@@ -681,6 +681,37 @@ return "ok"`.trim();
681
681
  return { ok: false, strategy: 'applescript', error: osaError(err) };
682
682
  }
683
683
  }
684
+ /**
685
+ * Conductor's workspace route restores a hidden session before selecting it.
686
+ * Keep the UI lease through the DB receipt: another focus operation must not
687
+ * navigate away while Conductor's asynchronous route handler is unhiding it.
688
+ * The callback is deliberately exact-id and read-only, supplied by the server.
689
+ */
690
+ export async function restoreChat(workspaceId, sessionId, isVisible) {
691
+ return uiTurn(async () => {
692
+ if (isVisible())
693
+ return { ok: true, strategy: 'deep-link', alreadyOpen: true };
694
+ if ((await screenLocked()) === true) {
695
+ return { ok: false, strategy: 'deep-link', error: `${MAC_LOCKED}. Unlock it, then try again.` };
696
+ }
697
+ try {
698
+ await exec('open', [workspaceLink(workspaceId, sessionId)], { timeout: 5_000 });
699
+ for (let attempt = 0; attempt < 40; attempt++) {
700
+ await new Promise(resolve => setTimeout(resolve, 250));
701
+ if (isVisible())
702
+ return { ok: true, strategy: 'deep-link' };
703
+ }
704
+ return {
705
+ ok: false,
706
+ strategy: 'deep-link',
707
+ error: 'Conductor has not restored this tab yet. Try again, or update Conductor on your Mac.'
708
+ };
709
+ }
710
+ catch (error) {
711
+ return { ok: false, strategy: 'deep-link', error: osaError(error) };
712
+ }
713
+ });
714
+ }
684
715
  /**
685
716
  * Press the Wi-Fi menu's row for a personal hotspot — Instant Hotspot, the same
686
717
  * button a human clicks. `networksetup` can only join a network that is
@@ -235,14 +235,20 @@ Search uses `GET /api/voice/search?q=…&limit=12&offset=0`, optionally with `ca
235
235
 
236
236
  ### Cost
237
237
 
238
- The default is `gpt-realtime-2.1-mini`. OpenAI currently publishes these per-million-token prices:
238
+ The default is `gpt-realtime-2.1`. Existing installations keep their saved model; switch them with:
239
+
240
+ ```bash
241
+ conductor-remote config set voice.model gpt-realtime-2.1
242
+ ```
243
+
244
+ This restarts the relay; start a new call to use the new model. OpenAI currently publishes these per-million-token prices for GPT-Realtime-2.1:
239
245
 
240
246
  | Modality | Input | Cached input | Output |
241
247
  | --- | ---: | ---: | ---: |
242
- | Text | $0.60 | $0.06 | $2.40 |
243
- | Audio | $10.00 | $0.30 | $20.00 |
248
+ | Text | $4.00 | $0.40 | $24.00 |
249
+ | Audio | $32.00 | $0.40 | $64.00 |
244
250
 
245
- The broker logs actual token usage and an estimate for every completed response. Use those lines for this workflow's real per-call cost; Twilio phone-number and PSTN charges are separate and depend on the account/country. The rate source is the [GPT-Realtime-2.1 Mini model page](https://developers.openai.com/api/docs/models/gpt-realtime-2.1-mini).
251
+ The broker logs actual token usage and an estimate for every completed response. Use those lines for this workflow's real per-call cost; Twilio phone-number and PSTN charges are separate and depend on the account/country. The rate source is the [GPT-Realtime-2.1 model page](https://developers.openai.com/api/docs/models/gpt-realtime-2.1). `gpt-realtime-2.1-mini` remains available through `voice.model`, with its own cost estimates using the [Mini model rates](https://developers.openai.com/api/docs/models/gpt-realtime-2.1-mini).
246
252
 
247
253
  ### Disable or rotate
248
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.113.0",
3
+ "version": "1.115.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.",