conductor-remote 1.114.0 → 1.116.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.
@@ -11,6 +11,8 @@ import { attachChangeStats } from "./change-stats.js";
11
11
  import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "./conductor-settings.js";
12
12
  import { loadConfig, stateDir } from "./config.js";
13
13
  import { ConductorDb } from "./db.js";
14
+ import { acceptDelegation, delegationHttpStatus } from "./delegation-intake.js";
15
+ import { delegatedPrompt } from "./delegation-prompt.js";
14
16
  import { DelegationQueue, DelegationStore } from "./delegations.js";
15
17
  import { DevServerController } from "./dev-server.js";
16
18
  import { isAllowedPreviewPath, parseFileReference, parseImageReference } from "./file-preview.js";
@@ -41,6 +43,8 @@ import { readSettings, writeSettings } from "./settings.js";
41
43
  import { attachmentTokens, isOpenAIRealtimeVoice, isVoiceLanguage, modelLabel, OPENAI_REALTIME_VOICES, responseErrorMessage, scrubWorkflowSecrets, timestampMs, VIEWING_HEADER, withoutClientWindowEvidence, withoutWindowEvidence, workspaceTitle } from "./shared.js";
42
44
  import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
43
45
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
46
+ import { isToolUsageRange } from "./tool-usage.js";
47
+ import { ToolUsageService } from "./tool-usage-service.js";
44
48
  import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
45
49
  import { recoverExpiredUiLease } from "./ui-lease-watchdog.js";
46
50
  import { VoiceBriefBoard } from "./voice/brief.js";
@@ -58,7 +62,7 @@ import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks }
58
62
  import { WorkflowCoordinator, WorkflowCoordinatorError } from "./workflow-coordinator.js";
59
63
  import { parseConfirmUiStableRequest, parseStartWorkflowRequest, parseWorkflowAdoptRequest, parseWorkflowCompleteRequest, parseWorkflowDelegateRequest, parseWorkflowReplayRequest, parseWorkflowRetryRequest, WorkflowRequestError, workflowClientIsMcp } from "./workflow-http.js";
60
64
  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";
65
+ 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
66
  // Before anything that logs: from here on every console line is also kept in memory for
63
67
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
64
68
  installLogCapture();
@@ -92,6 +96,7 @@ function stagedAttachmentIdsInObjective(objective) {
92
96
  // from a list before Conductor has created its first chat.
93
97
  const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
94
98
  const planUsage = new PlanUsageService();
99
+ const toolUsage = new ToolUsageService(cfg.dbPath);
95
100
  const roleStore = new RoleStore(path.join(stateDir(), 'roles.json'));
96
101
  const orchestration = new OrchestrationDb(path.join(stateDir(), 'orchestration.db'), {
97
102
  processProbe: processIdentityAlive
@@ -852,13 +857,6 @@ async function configureDelegation(job) {
852
857
  }
853
858
  return { ok: true };
854
859
  }
855
- function delegatedPrompt(job) {
856
- const handoff = job.handoff;
857
- if (!handoff)
858
- throw new Error('the delegated handoff is missing');
859
- const task = attachmentPrompt(handoff.token, job.prompt);
860
- return job.resolvedRole.preamble?.trim() ? `${job.resolvedRole.preamble.trim()}\n\n${task}` : task;
861
- }
862
860
  async function sendDelegation(job) {
863
861
  const ws = reads.getWorkspace(job.workspaceId);
864
862
  if (!ws)
@@ -1205,9 +1203,6 @@ function workflowFrozenError(sessionId) {
1205
1203
  }
1206
1204
  };
1207
1205
  }
1208
- function intakeError(code, message, retryable = false) {
1209
- return { ok: false, error: { code, message, retryable } };
1210
- }
1211
1206
  function workflowHttpError(error) {
1212
1207
  if (error instanceof WorkflowRequestError || error instanceof WorkflowGuardError) {
1213
1208
  return { status: error.status, error: { code: error.code, message: error.message, retryable: false } };
@@ -2340,6 +2335,12 @@ const server = http.createServer(async (req, res) => {
2340
2335
  if (isRoute(routes.planUsage, req.method, pathname)) {
2341
2336
  return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
2342
2337
  }
2338
+ if (isRoute(routes.toolUsage, req.method, pathname)) {
2339
+ const range = url.searchParams.get('range') ?? '24h';
2340
+ if (!isToolUsageRange(range))
2341
+ return json(req, res, 400, { error: 'Choose 24h, 7d, or 30d for tool usage.' });
2342
+ return json(req, res, 200, await toolUsage.read(range, url.searchParams.get('refresh') === '1'));
2343
+ }
2343
2344
  if (isRoute(routes.roles, req.method, pathname)) {
2344
2345
  const stored = roleStore.read();
2345
2346
  return json(req, res, 200, {
@@ -2436,7 +2437,7 @@ const server = http.createServer(async (req, res) => {
2436
2437
  });
2437
2438
  return json(req, res, 202, { workflow: accepted.workflow });
2438
2439
  }
2439
- // POST /api/workflows/:id/delegations — the sole agent mutation. The
2440
+ // POST /api/workflows/:id/delegations — managed delegation. The
2440
2441
  // capability, exact root, frozen role, and phase barrier are checked together.
2441
2442
  const workflowDelegation = routeParam(routes.workflowDelegation, req.method, pathname);
2442
2443
  if (workflowDelegation) {
@@ -3059,6 +3060,12 @@ const server = http.createServer(async (req, res) => {
3059
3060
  ...(Object.keys(sessionRoles).length ? { session_roles: sessionRoles } : {})
3060
3061
  });
3061
3062
  }
3063
+ const closedSessionsIn = routeParam(routes.closedSessions, req.method, pathname);
3064
+ if (closedSessionsIn) {
3065
+ if (!reads.getAnyWorkspace(closedSessionsIn))
3066
+ return json(req, res, 404, { error: 'workspace not found' });
3067
+ return json(req, res, 200, { sessions: reads.listClosedSessions(closedSessionsIn) });
3068
+ }
3062
3069
  // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
3063
3070
  const newChatIn = routeParam(routes.newChat, req.method, pathname);
3064
3071
  if (newChatIn) {
@@ -3422,6 +3429,23 @@ const server = http.createServer(async (req, res) => {
3422
3429
  session: reads.listSessions(ws.id).find(s => s.id === sessionId)
3423
3430
  });
3424
3431
  }
3432
+ const restoreChatId = routeParam(routes.restoreChat, req.method, pathname);
3433
+ if (restoreChatId) {
3434
+ const body = JSON.parse((await readBody(req)) || '{}');
3435
+ const ownerId = reads.sessionWorkspaceId(restoreChatId);
3436
+ if (!ownerId)
3437
+ return json(req, res, 404, { error: 'chat not found' });
3438
+ if (body.workspaceId && body.workspaceId !== ownerId) {
3439
+ return json(req, res, 409, { error: 'chat is not in that workspace' });
3440
+ }
3441
+ if (!reads.getWorkspace(ownerId)) {
3442
+ return json(req, res, 409, { error: 'Restore this workspace in Conductor before restoring its tabs.' });
3443
+ }
3444
+ const result = await restoreChat(ownerId, restoreChatId, () => reads.getSession(restoreChatId) !== null);
3445
+ if (!result.ok)
3446
+ return json(req, res, 502, result);
3447
+ return json(req, res, 200, { ...result, session: reads.getSession(restoreChatId) });
3448
+ }
3425
3449
  // DELETE /api/sessions/:id { workspaceId?, closeRunning? } — Conductor's
3426
3450
  // reversible Close tab action (Command-W). A running chat gets the same
3427
3451
  // explicit "Close anyway" gate as the desktop app.
@@ -3533,7 +3557,37 @@ const server = http.createServer(async (req, res) => {
3533
3557
  }
3534
3558
  const delegateFrom = routeParam(routes.delegateTask, req.method, pathname);
3535
3559
  if (delegateFrom) {
3536
- return json(req, res, 409, intakeError('workflow_required', 'delegate_task is available only inside a UI-authorized Workflow.'));
3560
+ if (!orchestration.writable) {
3561
+ throw new WorkflowCoordinatorError('workflow_incompatible_relay', orchestrationUnavailableReason(), {
3562
+ status: 409
3563
+ });
3564
+ }
3565
+ let body;
3566
+ try {
3567
+ body = JSON.parse((await readBody(req)) || '{}');
3568
+ }
3569
+ catch {
3570
+ return json(req, res, 400, {
3571
+ ok: false,
3572
+ error: { code: 'invalid_request', message: 'delegation must be valid JSON', retryable: false }
3573
+ });
3574
+ }
3575
+ const result = acceptDelegation(delegateFrom, body, {
3576
+ ownsSession: sessionId => workflowOwningSession(sessionId) !== null,
3577
+ sessionWorkspaceId: sessionId => reads.sessionWorkspaceId(sessionId),
3578
+ getSession: sessionId => reads.getSession(sessionId),
3579
+ getWorkspace: workspaceId => reads.getWorkspace(workspaceId),
3580
+ getMessages: sessionId => reads.getMessages(sessionId).entries,
3581
+ readRoles: () => roleStore.read(),
3582
+ models: () => modelCache.list(),
3583
+ enqueue: (workspace, job) => {
3584
+ const store = delegationStore(workspace);
3585
+ if (!store)
3586
+ throw new Error('worktree path unresolved');
3587
+ delegationQueue.enqueue(store, job);
3588
+ }
3589
+ });
3590
+ return json(req, res, result.ok ? 202 : delegationHttpStatus(result.error), result);
3537
3591
  }
3538
3592
  // POST /api/sessions/:id/prompt { text, agent? } — ordinary staged settings
3539
3593
  // are applied before the prompt so the two cannot come apart.
@@ -261,9 +261,8 @@ export function modelCatalogIncludes(model, groups) {
261
261
  return groups.some(group => group.models.some(candidate => modelPickerLabel(candidate) === wanted));
262
262
  }
263
263
  /**
264
- * Complete picker reads are whole-menu observations, not provider slices. Ignore
265
- * groups learned only from a successful single-model selection, then use the
266
- * newest complete observation to describe what Conductor can select.
264
+ * Return the latest observed menu, ignoring groups learned only from selections.
265
+ * A menu can expose only some providers; use currentModelCatalog for role choices.
267
266
  */
268
267
  export function newestModelSnapshot(groups) {
269
268
  let newest = null;
@@ -279,6 +278,41 @@ export function newestModelSnapshot(groups) {
279
278
  }
280
279
  return newest;
281
280
  }
281
+ /**
282
+ * Only an explicit whole-menu observation can retire a provider's older labels.
283
+ * Legacy entries have unknown provenance: successful selections used to look like
284
+ * menus too. They and known partial entries prove individual choices, never absence
285
+ * of other choices. A later real menu supersedes that positive evidence per provider.
286
+ * agentType names the chat that exposed the menu, not every model's provider.
287
+ */
288
+ export function currentModelCatalog(groups) {
289
+ const providers = new Map();
290
+ for (const group of groups) {
291
+ if (group.snapshotAt == null)
292
+ continue;
293
+ const observedAt = group.snapshotAt;
294
+ for (const provider of groupModelPickerLabels([...(group.snapshotModels ?? group.models)])) {
295
+ const current = providers.get(provider.label);
296
+ if (!current || observedAt >= current.observedAt) {
297
+ providers.set(provider.label, { observedAt, models: provider.models });
298
+ }
299
+ }
300
+ }
301
+ const models = new Set([...providers.values()].flatMap(provider => provider.models));
302
+ for (const group of groups) {
303
+ const selections = group.selections ??
304
+ (group.snapshotAt == null ? group.models.map(model => ({ model, selectedAt: group.updatedAt })) : []);
305
+ for (const selection of selections) {
306
+ const model = modelPickerLabel(selection.model).trim();
307
+ if (!model)
308
+ continue;
309
+ const menu = providers.get(modelProvider(model));
310
+ if (!menu || selection.selectedAt > menu.observedAt)
311
+ models.add(model);
312
+ }
313
+ }
314
+ return [...models].sort((a, b) => a.localeCompare(b));
315
+ }
282
316
  /** Stable, case-insensitive grouping shared by every model selector. */
283
317
  export function groupModelPickerLabels(models) {
284
318
  const grouped = new Map();
@@ -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
+ }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.114.0",
3
+ "version": "1.116.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.",