conductor-remote 1.99.0 → 1.101.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.
@@ -31,14 +31,24 @@ import { readPrefs, writePrefs } from "./prefs.js";
31
31
  import { Reads } from "./reads.js";
32
32
  import { decodeRoles, RoleStore, resolveRole, roleModelIssues } from "./roles.js";
33
33
  import { isRoute, routeParam, routes } from "./routes.js";
34
+ import { attachRunActivity } from "./run-activity.js";
34
35
  import { foldHits, queryTokens, SearchIndex } from "./search.js";
35
36
  import { SendOnce } from "./sendonce.js";
36
37
  import { SessionPoller } from "./session-poller.js";
37
38
  import { readSettings, writeSettings } from "./settings.js";
38
- import { responseErrorMessage, VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
39
+ import { isOpenAIRealtimeVoice, isVoiceLanguage, OPENAI_REALTIME_VOICES, responseErrorMessage, VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
39
40
  import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
40
41
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
41
42
  import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
43
+ import { VoiceBriefBoard } from "./voice/brief.js";
44
+ import { VoiceBroker } from "./voice/broker.js";
45
+ import { openAIOriginForSipHost, readVoiceConfig, voicePort } from "./voice/config.js";
46
+ import { createVoiceGateway } from "./voice/gateway.js";
47
+ import { PreviewStore } from "./voice/preview.js";
48
+ import { createVoiceServer } from "./voice/server.js";
49
+ import { mintSipTicket, missingTicketConfig } from "./voice/ticket.js";
50
+ import { createVoiceTools } from "./voice/tools.js";
51
+ import { createWebRtcCall, MAX_SDP_CHARS } from "./voice/webrtc.js";
42
52
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
43
53
  import { prepareWorkflowRoot, WORKFLOW_ROOT_ROLE } from "./workflow.js";
44
54
  import { archiveWorkspace, closeChat, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
@@ -150,6 +160,83 @@ const mcpTools = createTools(async (route, opts = {}) => {
150
160
  }
151
161
  return payload;
152
162
  });
163
+ // The voice process surface shares this process (and therefore the one UI lock) but not
164
+ // this server's port. Only its three scoped routes are mounted through Funnel.
165
+ const voiceConfig = readVoiceConfig();
166
+ // Stable but useless outside this OpenAI abuse-control header. Hashing the relay
167
+ // bearer means neither that bearer nor a device identifier leaves the Mac.
168
+ const voiceSafetyIdentifier = crypto.createHash('sha256').update(`conductor-remote:${cfg.token}`).digest('hex');
169
+ const voiceBoards = new Map();
170
+ const voicePreviews = new PreviewStore(path.join(stateDir(), 'voice-previews.json'));
171
+ const voiceBroker = voiceConfig.openaiKey
172
+ ? new VoiceBroker({
173
+ apiKey: voiceConfig.openaiKey,
174
+ apiOrigin: openAIOriginForSipHost(voiceConfig.sipHost),
175
+ model: voiceConfig.model,
176
+ voice: voiceConfig.voice,
177
+ mcpUrl: voiceConfig.publicBaseUrl ? `${voiceConfig.publicBaseUrl}/mcp` : null,
178
+ mcpToken: voiceConfig.mcpToken,
179
+ stateFile: path.join(stateDir(), 'voice-calls.json'),
180
+ tools: callId => voiceToolsForCall(callId),
181
+ onClose: callId => voiceBoards.delete(callId)
182
+ })
183
+ : null;
184
+ function voiceBoard(callId) {
185
+ let board = voiceBoards.get(callId);
186
+ if (board)
187
+ return board;
188
+ board = new VoiceBriefBoard({ reads, locked: async () => (await screenLocked()) === true, readPrefs, writePrefs });
189
+ voiceBoards.set(callId, board);
190
+ return board;
191
+ }
192
+ async function dispatchVoicePreview(preview) {
193
+ const host = !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host;
194
+ const timeoutMs = 75_000;
195
+ const res = await fetch(`http://${host}:${cfg.port}${routes.sendPrompt.path(preview.sessionId)}`, {
196
+ method: routes.sendPrompt.method,
197
+ signal: AbortSignal.timeout(timeoutMs),
198
+ headers: {
199
+ authorization: `Bearer ${cfg.token}`,
200
+ 'content-type': 'application/json',
201
+ 'x-relay-client': 'voice',
202
+ 'x-client-timeout-ms': String(timeoutMs)
203
+ },
204
+ body: JSON.stringify({
205
+ workspaceId: preview.workspaceId,
206
+ text: preview.text,
207
+ clientId: preview.token
208
+ })
209
+ });
210
+ const payload = (await res.json().catch(() => ({})));
211
+ return {
212
+ ok: payload.ok === true,
213
+ parked: payload.parked === true,
214
+ error: payload.error ?? (!res.ok ? `HTTP ${res.status}` : undefined)
215
+ };
216
+ }
217
+ function voiceToolsForCall(callId) {
218
+ return createVoiceTools({
219
+ callId,
220
+ board: voiceBoard(callId),
221
+ previews: voicePreviews,
222
+ findSession: sessionId => reads.listSessionStates().find(state => state.sessionId === sessionId) ?? null,
223
+ dispatch: dispatchVoicePreview,
224
+ announce: spoken => {
225
+ if (!voiceBroker?.inject(callId, spoken))
226
+ console.warn(`[voice] ${callId} could not receive a delivery nudge`);
227
+ }
228
+ });
229
+ }
230
+ const voiceGateway = createVoiceGateway({
231
+ config: () => voiceConfig,
232
+ broker: () => voiceBroker,
233
+ rpc: (callId, request) => handleRpc(voiceToolsForCall(callId), request)
234
+ });
235
+ const voiceServer = createVoiceServer({
236
+ routes: voiceGateway,
237
+ mcpToken: () => voiceConfig.mcpToken,
238
+ log: line => console.warn(line)
239
+ });
153
240
  // A windowless Conductor that ignores reopen *and* a Dock click can only be fixed
154
241
  // by restarting it — and quitting takes any agent mid-turn down with it. So the
155
242
  // write path may only do that while nothing is working, which is a DB fact, not
@@ -1357,6 +1444,7 @@ const server = http.createServer(async (req, res) => {
1357
1444
  const workspaces = reads.listWorkspaces();
1358
1445
  attachChangeStats(workspaces); // serves the cache now; refreshes stale git stats in the background
1359
1446
  attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
1447
+ attachRunActivity(workspaces); // flags a live Run wrapper from a cached ps snapshot
1360
1448
  attachDelegationState(workspaces);
1361
1449
  // An undelivered first prompt rides along with its workspace: the phone renders it
1362
1450
  // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
@@ -1565,6 +1653,73 @@ const server = http.createServer(async (req, res) => {
1565
1653
  }
1566
1654
  });
1567
1655
  }
1656
+ // POST /api/voice/ticket — the native app presents the same relay bearer as
1657
+ // the PWA, and receives only a two-minute SIP URI. The OpenAI key, webhook
1658
+ // secret and marker key never leave this Mac.
1659
+ if (isRoute(routes.voiceTicket, req.method, pathname)) {
1660
+ const missing = missingTicketConfig(voiceConfig);
1661
+ if (missing.length || !voiceBroker) {
1662
+ return json(req, res, 503, {
1663
+ error: 'voice calls are not fully configured on this relay',
1664
+ missing
1665
+ });
1666
+ }
1667
+ return json(req, res, 200, mintSipTicket(voiceConfig));
1668
+ }
1669
+ // POST /api/voice/calls — the PWA sends its SDP offer to this authenticated
1670
+ // relay. The relay combines it with the global orchestrator session and
1671
+ // keeps OpenAI's permanent key and every function tool on the Mac.
1672
+ if (isRoute(routes.voiceCall, req.method, pathname)) {
1673
+ if (!voiceConfig.openaiKey || !voiceBroker)
1674
+ return json(req, res, 503, { error: 'voice needs an OpenAI API key on this relay' });
1675
+ const raw = await readBody(req);
1676
+ if (raw.length > MAX_SDP_CHARS * 2)
1677
+ return json(req, res, 413, { error: 'WebRTC offer is too large' });
1678
+ const body = JSON.parse(raw || '{}');
1679
+ if (typeof body.sdp !== 'string' || !body.sdp.trim())
1680
+ return json(req, res, 400, { error: 'WebRTC offer is required' });
1681
+ if (body.sdp.length > MAX_SDP_CHARS)
1682
+ return json(req, res, 413, { error: 'WebRTC offer is too large' });
1683
+ if (!isOpenAIRealtimeVoice(body.voice))
1684
+ return json(req, res, 400, { error: `voice must be one of ${OPENAI_REALTIME_VOICES.join(', ')}` });
1685
+ if (!isVoiceLanguage(body.language))
1686
+ return json(req, res, 400, { error: 'unsupported voice language' });
1687
+ try {
1688
+ const call = await createWebRtcCall(voiceConfig.openaiKey, openAIOriginForSipHost(voiceConfig.sipHost), body.sdp, {
1689
+ model: voiceConfig.model,
1690
+ voice: body.voice,
1691
+ language: body.language
1692
+ }, voiceSafetyIdentifier);
1693
+ voiceBroker.registerWebRtc(call.callId);
1694
+ return json(req, res, 200, call);
1695
+ }
1696
+ catch (err) {
1697
+ console.warn('[voice] could not create WebRTC orchestrator call:', err);
1698
+ return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice call failed' });
1699
+ }
1700
+ }
1701
+ const readyVoiceCall = routeParam(routes.voiceCallReady, req.method, pathname);
1702
+ if (readyVoiceCall) {
1703
+ if (!voiceBroker)
1704
+ return json(req, res, 503, { error: 'voice is not configured on this relay' });
1705
+ if (!voiceBroker.beginWebRtc(readyVoiceCall))
1706
+ return json(req, res, 404, { error: 'voice call not found' });
1707
+ return json(req, res, 200, { ok: true });
1708
+ }
1709
+ const endedVoiceCall = routeParam(routes.voiceCallEnd, req.method, pathname);
1710
+ if (endedVoiceCall) {
1711
+ if (!voiceBroker)
1712
+ return json(req, res, 503, { error: 'voice is not configured on this relay' });
1713
+ try {
1714
+ if (!(await voiceBroker.hangupWebRtc(endedVoiceCall)))
1715
+ return json(req, res, 404, { error: 'voice call not found' });
1716
+ return json(req, res, 200, { ok: true });
1717
+ }
1718
+ catch (err) {
1719
+ console.warn('[voice] could not hang up WebRTC orchestrator call:', err);
1720
+ return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice hangup failed' });
1721
+ }
1722
+ }
1568
1723
  // GET /api/settings — relay preferences plus what the phone needs to edit them:
1569
1724
  // the SSIDs this Mac already holds credentials for, so the picker offers a choice
1570
1725
  // instead of asking someone to type a network name from memory on a phone keyboard.
@@ -2757,6 +2912,10 @@ const server = http.createServer(async (req, res) => {
2757
2912
  });
2758
2913
  });
2759
2914
  server.listen(cfg.port, cfg.host, () => {
2915
+ voiceServer.listen(voicePort(), '127.0.0.1', () => {
2916
+ console.info(` voice: 127.0.0.1:${voicePort()}${voiceBroker ? '' : ' (waiting for OpenAI config)'}`);
2917
+ void voiceBroker?.restore();
2918
+ });
2760
2919
  // Under `yarn dev` the app comes from Vite and only /api comes from here, so the URL worth
2761
2920
  // printing is Vite's — carrying the token, which Vite itself has no way to print.
2762
2921
  const dev = cfg.devWebPort !== undefined;
@@ -231,6 +231,32 @@ export function responseErrorMessage(error, fallback) {
231
231
  */
232
232
  export const HIT_OPEN = '\u0001';
233
233
  export const HIT_CLOSE = '\u0002';
234
+ /**
235
+ * OpenAI's built-in Realtime voices, in the order the phone presents them. The
236
+ * two voices OpenAI recommends for quality lead the list; every value is shared
237
+ * with the relay so a stale or hand-written client cannot ask it to configure an
238
+ * arbitrary voice id.
239
+ */
240
+ export const OPENAI_REALTIME_VOICES = [
241
+ 'marin',
242
+ 'cedar',
243
+ 'alloy',
244
+ 'ash',
245
+ 'ballad',
246
+ 'coral',
247
+ 'echo',
248
+ 'sage',
249
+ 'shimmer',
250
+ 'verse'
251
+ ];
252
+ /** The deliberately small language picker for the first voice surface. */
253
+ export const VOICE_LANGUAGES = ['auto', 'no', 'en'];
254
+ export function isOpenAIRealtimeVoice(value) {
255
+ return typeof value === 'string' && OPENAI_REALTIME_VOICES.includes(value);
256
+ }
257
+ export function isVoiceLanguage(value) {
258
+ return typeof value === 'string' && VOICE_LANGUAGES.includes(value);
259
+ }
234
260
  const ATTACHMENT_PREFIX = '.context/attachments/';
235
261
  /**
236
262
  * Read Conductor attachment tokens from prompt text.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Speech bounding — the two helpers that keep a spoken field inside its budget.
3
+ *
4
+ * The voice orchestrator's caps are enforced in relay code rather than in the model's
5
+ * prompt (design ▸ Constraints), because a prompt that asks for brevity is a request and
6
+ * a cap is a guarantee. Both callers here are budgets someone else set: a push body has
7
+ * a payload ceiling, and a roll call has an attention ceiling — roughly 600 characters
8
+ * before a listener stops holding it.
9
+ *
10
+ * `clipExact` is named for the property `notify.ts`'s private `clip` did not have: the
11
+ * ellipsis counts against the cap, so the result is never longer than what was asked for.
12
+ * The old helper returned `max + 1` characters, which was invisible against a byte budget
13
+ * carrying eight bytes of slack and would not be invisible against a spoken one.
14
+ *
15
+ * Stdlib-free on purpose, so a fixture test needs nothing but the function.
16
+ */
17
+ /** Cut back to the last word boundary when one is close enough to the cut to be worth it. */
18
+ const WORD_LOOKBACK = 0.2;
19
+ /**
20
+ * At most `max` characters, ellipsis included. Prefers a word boundary near the cut, so a
21
+ * clipped sentence ends on a word rather than mid-syllable, which matters when it is read
22
+ * aloud rather than shown.
23
+ */
24
+ export function clipExact(text, max) {
25
+ if (max <= 0)
26
+ return '';
27
+ if (text.length <= max)
28
+ return text;
29
+ if (max === 1)
30
+ return '…';
31
+ const hard = text.slice(0, max - 1);
32
+ // Only back off when the cut actually lands inside a word. A cut that already falls on a
33
+ // space has nothing to repair, and backing off anyway throws away a whole word for free.
34
+ const splitsWord = !/\s/.test(text.charAt(max - 1));
35
+ const space = hard.lastIndexOf(' ');
36
+ const body = splitsWord && space >= Math.floor((max - 1) * (1 - WORD_LOOKBACK)) ? hard.slice(0, space) : hard;
37
+ return `${body.trimEnd()}…`;
38
+ }
39
+ /**
40
+ * One spoken line: code fences become an ellipsis, every run of whitespace becomes one
41
+ * space, then the whole thing is clipped. A transcript entry is written to be read on a
42
+ * screen, so its newlines and fences are layout that speech has no use for.
43
+ */
44
+ export function oneLine(text, max) {
45
+ return clipExact(text
46
+ .replace(/```[\s\S]*?```/g, '…')
47
+ .replace(/\s+/g, ' ')
48
+ .trim(), max);
49
+ }
50
+ /**
51
+ * Turn the Markdown that is useful on screen into prose that is useful in an
52
+ * ear. The visible transcript remains untouched and canonical: this only drops
53
+ * things that a speech engine would otherwise pronounce literally (URLs,
54
+ * backticks, heading marks, and fenced source code).
55
+ */
56
+ export function speechText(text, max) {
57
+ return clipExact(text
58
+ .replace(/```[\s\S]*?```/g, '…')
59
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
60
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
61
+ .replace(/`([^`\n]+)`/g, '$1')
62
+ .replace(/https?:\/\/\S+/g, 'link')
63
+ .replace(/^\s{0,3}(?:#{1,6}|>|[-*+])\s+/gm, '')
64
+ .replace(/<[^>]+>/g, ' ')
65
+ .replace(/[ \t]+/g, ' ')
66
+ .replace(/\n{3,}/g, '\n\n')
67
+ .trim(), max);
68
+ }
@@ -0,0 +1,260 @@
1
+ import { clipExact, oneLine, speechText } from "../speech.js";
2
+ const DORMANT_MS = 7 * 24 * 60 * 60 * 1000;
3
+ const DORMANT_LABELS = new Set(['backlog', 'done', 'canceled', 'cancelled']);
4
+ const OVERVIEW_PAGE_SIZE = 3;
5
+ function clean(text) {
6
+ return text
7
+ .replace(/[*_`#]+/g, '')
8
+ .replace(/\s+/g, ' ')
9
+ .trim();
10
+ }
11
+ function lastParagraph(text) {
12
+ return (text
13
+ .trim()
14
+ .split(/\n\s*\n/)
15
+ .map(clean)
16
+ .filter(Boolean)
17
+ .at(-1) ?? '');
18
+ }
19
+ /** Parse `D<N> —`, lettered choices, `(recommended)`, and `Recommendation:` briefs. */
20
+ export function parseProseDecision(text) {
21
+ const lines = text.split('\n');
22
+ const heading = lines.findIndex(line => /^\s*#{0,6}\s*\**D\d+\s*[—:-]\s*/i.test(line));
23
+ if (heading < 0)
24
+ return null;
25
+ const question = clean(lines[heading].replace(/^\s*#{0,6}\s*\**D\d+\s*[—:-]\s*/i, ''));
26
+ if (!question)
27
+ return null;
28
+ const options = [];
29
+ let consequence = '';
30
+ for (let i = heading + 1; i < lines.length; i++) {
31
+ const line = clean(lines[i]);
32
+ if (/^[A-Z][.)]\s+/.test(line))
33
+ options.push(line);
34
+ else if (/^Recommendation\s*:/i.test(line))
35
+ consequence = line.replace(/^Recommendation\s*:\s*/i, '');
36
+ }
37
+ if (!options.length)
38
+ return null;
39
+ const before = lines.slice(0, heading).join('\n');
40
+ return { situation: lastParagraph(before), question, options, consequence };
41
+ }
42
+ function object(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
44
+ ? value
45
+ : null;
46
+ }
47
+ /** Parse the input shape used by AskUserQuestion without depending on a provider SDK. */
48
+ export function parseStructuredQuestion(input) {
49
+ const root = object(input);
50
+ const first = Array.isArray(root?.questions) ? object(root.questions[0]) : null;
51
+ if (!first || typeof first.question !== 'string' || !first.question.trim())
52
+ return null;
53
+ const rawOptions = Array.isArray(first.options) ? first.options : [];
54
+ const options = rawOptions.flatMap((raw, index) => {
55
+ const option = object(raw);
56
+ if (!option || typeof option.label !== 'string' || !option.label.trim())
57
+ return [];
58
+ const letter = String.fromCharCode(65 + index);
59
+ const description = typeof option.description === 'string' ? clean(option.description) : '';
60
+ return [`${letter}. ${clean(option.label)}${description ? ` — ${description}` : ''}`];
61
+ });
62
+ if (!options.length)
63
+ return null;
64
+ return { situation: '', question: clean(first.question), options, consequence: '' };
65
+ }
66
+ function parseDate(value) {
67
+ if (!value)
68
+ return 0;
69
+ // SQLite values have no zone marker but are UTC in Conductor's DB.
70
+ const normalized = value.includes('T') || /Z$/.test(value) ? value : `${value.replace(' ', 'T')}Z`;
71
+ const parsed = Date.parse(normalized);
72
+ return Number.isFinite(parsed) ? parsed : 0;
73
+ }
74
+ function statusLabel(workspace) {
75
+ return workspace.manual_status ?? workspace.derived_status;
76
+ }
77
+ function isDormant(state, workspace, now) {
78
+ if (state.status !== 'idle' && state.status)
79
+ return false;
80
+ const old = now - parseDate(state.updatedAt) > DORMANT_MS;
81
+ const labelled = DORMANT_LABELS.has((statusLabel(workspace) ?? '').toLowerCase());
82
+ return old || labelled;
83
+ }
84
+ function overviewRank(state) {
85
+ if (state.status === 'error')
86
+ return 0;
87
+ if (state.status === 'needs_user_input' || state.status === 'needs_plan_response')
88
+ return 1;
89
+ if (state.status === 'working')
90
+ return 2;
91
+ return 3;
92
+ }
93
+ function overviewStatus(state, workspace) {
94
+ if (state.status === 'error')
95
+ return 'has an error';
96
+ if (state.status === 'needs_user_input' || state.status === 'needs_plan_response')
97
+ return 'needs you';
98
+ if (state.status === 'working')
99
+ return 'is working';
100
+ if (workspace.unread_sessions.some(session => session.id === state.sessionId))
101
+ return 'has an unread update';
102
+ return 'is recently active';
103
+ }
104
+ function fallbackDecision(state, said) {
105
+ const last = lastParagraph(said) || `${state.workspaceTitle} needs attention.`;
106
+ return {
107
+ situation: last,
108
+ question: state.status === 'error' ? 'What should the agent try next?' : 'What should this agent do next?',
109
+ options: ['A. Send a focused instruction.', 'B. Skip this item for now.'],
110
+ consequence: 'Sending unblocks the owning workspace; skipping leaves it in the queue.'
111
+ };
112
+ }
113
+ function spokenDecision(item) {
114
+ const d = item.decision;
115
+ const fields = [
116
+ `Situation: ${item.title}. ${d.situation}`,
117
+ `Decision needed: ${d.question}`,
118
+ `Safe options: ${d.options.join(' ')}`,
119
+ d.consequence ? `Consequence: ${d.consequence}` : ''
120
+ ]
121
+ .filter(Boolean)
122
+ .map(field => oneLine(field, 180));
123
+ return clipExact(fields.join(' '), 400);
124
+ }
125
+ export class VoiceBriefBoard {
126
+ deps;
127
+ now;
128
+ cached = null;
129
+ constructor(deps) {
130
+ this.deps = deps;
131
+ this.now = deps.now ?? Date.now;
132
+ }
133
+ build() {
134
+ const workspaces = new Map(this.deps.reads.listWorkspaces().map(ws => [ws.id, ws]));
135
+ const marks = this.deps.readPrefs().readMarks;
136
+ let working = 0;
137
+ let dormant = 0;
138
+ const queue = [];
139
+ for (const state of this.deps.reads.listSessionStates()) {
140
+ const workspace = workspaces.get(state.workspaceId);
141
+ if (!workspace)
142
+ continue;
143
+ if (state.status === 'working') {
144
+ working++;
145
+ continue;
146
+ }
147
+ if (isDormant(state, workspace, this.now())) {
148
+ dormant++;
149
+ continue;
150
+ }
151
+ if ((marks[state.sessionId] ?? '') >= state.updatedAt)
152
+ continue;
153
+ const said = this.deps.reads.lastAssistantText(state.sessionId) ?? '';
154
+ const prose = parseProseDecision(said);
155
+ const structured = prose ? null : parseStructuredQuestion(this.deps.reads.lastQuestionInput(state.sessionId));
156
+ const hasQuestion = Boolean(prose ?? structured) || /\?\s*$/.test(said.trim());
157
+ const unread = workspace.unread_sessions.some(s => s.id === state.sessionId);
158
+ const priority = state.status === 'error'
159
+ ? 0
160
+ : state.status === 'needs_user_input' || state.status === 'needs_plan_response'
161
+ ? 1
162
+ : hasQuestion
163
+ ? 2
164
+ : unread
165
+ ? 3
166
+ : 4;
167
+ queue.push({
168
+ workspaceId: state.workspaceId,
169
+ sessionId: state.sessionId,
170
+ title: state.sessionTitle ? `${state.workspaceTitle}, ${state.sessionTitle}` : state.workspaceTitle,
171
+ updatedAt: state.updatedAt,
172
+ priority,
173
+ decision: prose ?? structured ?? fallbackDecision(state, said)
174
+ });
175
+ }
176
+ queue.sort((a, b) => a.priority - b.priority || b.updatedAt.localeCompare(a.updatedAt));
177
+ return { queue, working, dormant };
178
+ }
179
+ items() {
180
+ if (!this.cached)
181
+ this.cached = this.build().queue;
182
+ return this.cached;
183
+ }
184
+ /** A deliberately uncached read: a new overview must not replay the call-opening tally. */
185
+ async workspaceOverview(cursor = 0) {
186
+ const workspaces = new Map(this.deps.reads.listWorkspaces().map(workspace => [workspace.id, workspace]));
187
+ const grouped = new Map();
188
+ for (const state of this.deps.reads.listSessionStates()) {
189
+ if (!workspaces.has(state.workspaceId))
190
+ continue;
191
+ const states = grouped.get(state.workspaceId) ?? [];
192
+ states.push(state);
193
+ grouped.set(state.workspaceId, states);
194
+ }
195
+ let dormant = 0;
196
+ const items = [];
197
+ for (const [workspaceId, workspace] of workspaces) {
198
+ const current = (grouped.get(workspaceId) ?? []).filter(state => !isDormant(state, workspace, this.now()));
199
+ if (!current.length) {
200
+ dormant++;
201
+ continue;
202
+ }
203
+ current.sort((a, b) => overviewRank(a) - overviewRank(b) || parseDate(b.updatedAt) - parseDate(a.updatedAt));
204
+ const state = current[0];
205
+ const said = this.deps.reads.lastAssistantText(state.sessionId) ?? '';
206
+ items.push({
207
+ workspaceId,
208
+ sessionId: state.sessionId,
209
+ title: state.sessionTitle ? `${state.workspaceTitle}, ${state.sessionTitle}` : state.workspaceTitle,
210
+ status: overviewStatus(state, workspace),
211
+ updatedAt: state.updatedAt,
212
+ update: oneLine(speechText(said, 150), 150),
213
+ rank: overviewRank(state)
214
+ });
215
+ }
216
+ items.sort((a, b) => a.rank - b.rank || parseDate(b.updatedAt) - parseDate(a.updatedAt));
217
+ const offset = Number.isFinite(cursor) ? Math.max(0, Math.floor(cursor)) : 0;
218
+ const rankedPage = items.slice(offset, offset + OVERVIEW_PAGE_SIZE);
219
+ const page = rankedPage.map(({ rank: _rank, ...item }) => item);
220
+ const next = offset + page.length < items.length ? offset + page.length : null;
221
+ const noun = items.length === 1 ? 'workspace' : 'workspaces';
222
+ const lines = page.map(item => `${item.title} ${item.status}.${item.update ? ` ${item.update}` : ' No agent update yet.'}`);
223
+ const more = next === null ? '' : ` ${items.length - next} more current; ask me to continue.`;
224
+ const none = items.length ? '' : ' Nothing is active right now.';
225
+ return {
226
+ spoken: clipExact(`Fresh overview: ${items.length} current ${noun}, ${dormant} dormant. ${lines.join(' ')}${more}${none}`.trim(), 700),
227
+ current: items.length,
228
+ dormant,
229
+ cursor: next,
230
+ workspaces: page
231
+ };
232
+ }
233
+ async rollCall() {
234
+ const built = this.build();
235
+ this.cached = built.queue;
236
+ const locked = await this.deps.locked();
237
+ const needsYou = built.queue.filter(item => item.priority < 4).length;
238
+ const heads = built.queue.slice(0, 3).map(item => item.title);
239
+ const spoken = clipExact(`${locked ? 'Mac is locked; sends will park.' : 'Mac is unlocked; sends can land.'} ${built.working} working, ${needsYou} need you, ${built.dormant} dormant.${heads.length ? ` Queue starts with ${heads.join(', ')}.` : ''}`, 600);
240
+ return { spoken, working: built.working, needsYou, dormant: built.dormant, queue: built.queue };
241
+ }
242
+ async nextDecision(cursor = 0) {
243
+ const item = this.items()[Math.max(0, Math.floor(cursor))];
244
+ if (!item)
245
+ return null;
246
+ return {
247
+ spoken: spokenDecision(item),
248
+ cursor: Math.max(0, Math.floor(cursor)) + 1,
249
+ workspaceId: item.workspaceId,
250
+ sessionId: item.sessionId
251
+ };
252
+ }
253
+ /** A dispatch or an explicit spoken skip is handled; merely hearing the item is not. */
254
+ markHandled(sessionId) {
255
+ const item = this.items().find(candidate => candidate.sessionId === sessionId);
256
+ if (!item)
257
+ return;
258
+ this.deps.writePrefs({ readMarks: { [sessionId]: item.updatedAt } });
259
+ }
260
+ }