conductor-remote 1.110.0 → 1.111.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,7 +25,7 @@
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-Bi66ei7k.js"></script>
28
+ <script type="module" crossorigin src="/assets/index-Dg6_e4df.js"></script>
29
29
  <link rel="stylesheet" crossorigin href="/assets/index-iryu1HEQ.css">
30
30
  <link rel="manifest" href="/manifest.webmanifest"></head>
31
31
  <body>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const 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:"e3a14ecc2f253f78577bed2a5529438c"},{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-iryu1HEQ.css",revision:null},{url:"assets/index-Bi66ei7k.js",revision:null},{url:"assets/PierrePatch-YcYgYId9.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 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:"5c4dca29374e8cbef37b975e6844f509"},{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-iryu1HEQ.css",revision:null},{url:"assets/index-Dg6_e4df.js",revision:null},{url:"assets/PierrePatch-QISkVnaE.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")});
@@ -224,14 +224,31 @@ function voiceBoard(callId) {
224
224
  let board = voiceBoards.get(callId);
225
225
  if (board)
226
226
  return board;
227
- board = new VoiceBriefBoard({ reads, locked: async () => (await screenLocked()) === true, readPrefs, writePrefs });
227
+ board = new VoiceBriefBoard({
228
+ reads: {
229
+ listWorkspaces: () => {
230
+ const workspaces = reads.listWorkspaces();
231
+ attachPrStatus(workspaces);
232
+ return workspaces;
233
+ },
234
+ listSessionStates: () => reads.listSessionStates(),
235
+ lastAssistantText: sessionId => reads.lastAssistantText(sessionId),
236
+ lastQuestionInput: sessionId => reads.lastQuestionInput(sessionId)
237
+ },
238
+ locked: async () => (await screenLocked()) === true,
239
+ readPrefs,
240
+ writePrefs
241
+ });
228
242
  voiceBoards.set(callId, board);
229
243
  return board;
230
244
  }
231
- async function dispatchVoicePreview(preview) {
245
+ function voiceRelayOrigin() {
232
246
  const host = !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host;
247
+ return `http://${host}:${cfg.port}`;
248
+ }
249
+ async function dispatchVoicePreview(preview) {
233
250
  const timeoutMs = 75_000;
234
- const res = await fetch(`http://${host}:${cfg.port}${routes.sendPrompt.path(preview.sessionId)}`, {
251
+ const res = await fetch(`${voiceRelayOrigin()}${routes.sendPrompt.path(preview.sessionId)}`, {
235
252
  method: routes.sendPrompt.method,
236
253
  signal: AbortSignal.timeout(timeoutMs),
237
254
  headers: {
@@ -253,12 +270,38 @@ async function dispatchVoicePreview(preview) {
253
270
  error: payload.error ?? (!res.ok ? `HTTP ${res.status}` : undefined)
254
271
  };
255
272
  }
273
+ async function createVoiceWorkspace(preview) {
274
+ // Creation runs behind the shared Conductor UI lease. The call already returned
275
+ // "queued", so keep enough room for one in-flight interactive action to finish
276
+ // before the deep link and its workspace-row receipt run.
277
+ const timeoutMs = 75_000;
278
+ const res = await fetch(`${voiceRelayOrigin()}${routes.createWorkspace.path()}`, {
279
+ method: routes.createWorkspace.method,
280
+ signal: AbortSignal.timeout(timeoutMs),
281
+ headers: {
282
+ authorization: `Bearer ${cfg.token}`,
283
+ 'content-type': 'application/json',
284
+ 'x-relay-client': 'voice',
285
+ 'x-client-timeout-ms': String(timeoutMs)
286
+ },
287
+ body: JSON.stringify({ repo: preview.repo, prompt: preview.prompt, sendImmediately: true })
288
+ });
289
+ const payload = (await res.json().catch(() => ({})));
290
+ return {
291
+ ok: res.ok && payload.ok === true,
292
+ workspaceId: payload.workspaceId,
293
+ warning: payload.warning,
294
+ error: payload.error ?? (!res.ok ? `HTTP ${res.status}` : undefined)
295
+ };
296
+ }
256
297
  function voiceToolsForCall(callId) {
257
298
  return createVoiceTools({
258
299
  callId,
259
300
  board: voiceBoard(callId),
260
301
  previews: voicePreviews,
261
302
  findSession: sessionId => reads.listSessionStates().find(state => state.sessionId === sessionId) ?? null,
303
+ listRepos: () => reads.listRepos().map(repo => ({ name: repo.name, defaultBranch: repo.default_branch })),
304
+ createWorkspace: createVoiceWorkspace,
262
305
  dispatch: dispatchVoicePreview,
263
306
  announce: spoken => {
264
307
  if (!voiceBroker?.inject(callId, spoken))
@@ -2,6 +2,7 @@ import { clipExact, oneLine, speechText } from "../speech.js";
2
2
  const DORMANT_MS = 7 * 24 * 60 * 60 * 1000;
3
3
  const DORMANT_LABELS = new Set(['backlog', 'done', 'canceled', 'cancelled']);
4
4
  const OVERVIEW_PAGE_SIZE = 3;
5
+ const OVERVIEW_AGENT_STATUSES = new Set(['working', 'idle', 'error', 'needs-you']);
5
6
  function clean(text) {
6
7
  return text
7
8
  .replace(/[*_`#]+/g, '')
@@ -74,12 +75,102 @@ function parseDate(value) {
74
75
  function statusLabel(workspace) {
75
76
  return workspace.manual_status ?? workspace.derived_status;
76
77
  }
77
- function isDormant(state, workspace, now) {
78
+ function normalizedStatus(value) {
79
+ return (value ?? '').trim().toLowerCase().replaceAll('_', '-').replaceAll(' ', '-');
80
+ }
81
+ function isDormant(state, workspace, now, options = {}) {
78
82
  if (state.status !== 'idle' && state.status)
79
83
  return false;
80
84
  const old = now - parseDate(state.updatedAt) > DORMANT_MS;
81
- const labelled = DORMANT_LABELS.has((statusLabel(workspace) ?? '').toLowerCase());
82
- return old || labelled;
85
+ const label = normalizedStatus(statusLabel(workspace));
86
+ const explicitlyIncluded = options.includedWorkspaceStatus === label || (options.includeDone && label === 'done');
87
+ const labelled = DORMANT_LABELS.has(label) && !explicitlyIncluded;
88
+ return (!options.ignoreAge && old) || labelled;
89
+ }
90
+ function isDone(workspace) {
91
+ return normalizedStatus(statusLabel(workspace)) === 'done';
92
+ }
93
+ function isMerged(workspace) {
94
+ return workspace.pr_status === 'merged';
95
+ }
96
+ function parseOverviewDate(value, now, field) {
97
+ const normalized = value.trim().toLowerCase();
98
+ if (normalized === 'today' ||
99
+ normalized === 'yesterday' ||
100
+ normalized === 'this-week' ||
101
+ normalized === 'this-month') {
102
+ const current = new Date(now);
103
+ const boundary = new Date(current.getFullYear(), current.getMonth(), current.getDate());
104
+ if (normalized === 'yesterday')
105
+ boundary.setDate(boundary.getDate() - 1);
106
+ if (normalized === 'this-week') {
107
+ const daysSinceMonday = (boundary.getDay() + 6) % 7;
108
+ boundary.setDate(boundary.getDate() - daysSinceMonday);
109
+ }
110
+ if (normalized === 'this-month')
111
+ boundary.setDate(1);
112
+ return boundary.getTime();
113
+ }
114
+ const relative = /^(\d+)(h|d|w)$/.exec(normalized);
115
+ if (relative) {
116
+ const amount = Number(relative[1]);
117
+ const unit = relative[2];
118
+ const multiplier = unit === 'h' ? 60 * 60 * 1000 : unit === 'd' ? 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000;
119
+ if (amount > 0 && amount <= 10_000)
120
+ return now - amount * multiplier;
121
+ }
122
+ if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
123
+ const [year, month, day] = normalized.split('-').map(Number);
124
+ const date = new Date(year, month - 1, day);
125
+ if (date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day)
126
+ return date.getTime();
127
+ }
128
+ const parsed = Date.parse(value);
129
+ if (Number.isFinite(parsed))
130
+ return parsed;
131
+ throw new Error(`${field} must be today, yesterday, this-week, this-month, a duration like 24h or 7d, or an ISO date/time`);
132
+ }
133
+ function agentStatus(state) {
134
+ if (state.status === 'needs_user_input' || state.status === 'needs_plan_response')
135
+ return 'needs-you';
136
+ if (state.status === 'working' || state.status === 'error')
137
+ return state.status;
138
+ return 'idle';
139
+ }
140
+ function spokenUpdateAge(value, now) {
141
+ const at = parseDate(value);
142
+ if (!at)
143
+ return 'Update time unavailable.';
144
+ const elapsed = Math.max(0, now - at);
145
+ const minutes = Math.floor(elapsed / (60 * 1000));
146
+ if (minutes < 1)
147
+ return 'Updated just now.';
148
+ if (minutes < 60)
149
+ return `Updated ${minutes} minute${minutes === 1 ? '' : 's'} ago.`;
150
+ const hours = Math.floor(elapsed / (60 * 60 * 1000));
151
+ if (hours < 24)
152
+ return `Updated ${hours} hour${hours === 1 ? '' : 's'} ago.`;
153
+ const days = Math.floor(elapsed / (24 * 60 * 60 * 1000));
154
+ if (days === 1)
155
+ return 'Updated yesterday.';
156
+ if (days < 31)
157
+ return `Updated ${days} days ago.`;
158
+ const date = new Date(at);
159
+ const month = [
160
+ 'January',
161
+ 'February',
162
+ 'March',
163
+ 'April',
164
+ 'May',
165
+ 'June',
166
+ 'July',
167
+ 'August',
168
+ 'September',
169
+ 'October',
170
+ 'November',
171
+ 'December'
172
+ ][date.getUTCMonth()];
173
+ return `Updated on ${month} ${date.getUTCDate()}, ${date.getUTCFullYear()}.`;
83
174
  }
84
175
  function overviewRank(state) {
85
176
  if (state.status === 'error')
@@ -182,7 +273,22 @@ export class VoiceBriefBoard {
182
273
  return this.cached;
183
274
  }
184
275
  /** A deliberately uncached read: a new overview must not replay the call-opening tally. */
185
- async workspaceOverview(cursor = 0) {
276
+ async workspaceOverview(cursor = 0, filters = {}) {
277
+ if (filters.agentStatus && !OVERVIEW_AGENT_STATUSES.has(filters.agentStatus))
278
+ throw new Error('agent_status must be working, idle, error, or needs-you');
279
+ const now = this.now();
280
+ const updatedSince = filters.updatedSince ? parseOverviewDate(filters.updatedSince, now, 'updated_since') : null;
281
+ const updatedBefore = filters.updatedBefore ? parseOverviewDate(filters.updatedBefore, now, 'updated_before') : null;
282
+ if (updatedSince !== null && updatedBefore !== null && updatedSince >= updatedBefore)
283
+ throw new Error('updated_since must be earlier than updated_before');
284
+ const wantedWorkspaceStatus = filters.workspaceStatus ? normalizedStatus(filters.workspaceStatus) : null;
285
+ const completedOnly = wantedWorkspaceStatus === 'done' || filters.prStatus === 'merged';
286
+ const includeDone = filters.includeDone === true || completedOnly;
287
+ const includeMerged = filters.includeMerged === true || completedOnly;
288
+ const ignoreDormantAge = updatedSince !== null ||
289
+ updatedBefore !== null ||
290
+ wantedWorkspaceStatus !== null ||
291
+ filters.prStatus !== undefined;
186
292
  const workspaces = new Map(this.deps.reads.listWorkspaces().map(workspace => [workspace.id, workspace]));
187
293
  const grouped = new Map();
188
294
  for (const state of this.deps.reads.listSessionStates()) {
@@ -193,20 +299,56 @@ export class VoiceBriefBoard {
193
299
  grouped.set(state.workspaceId, states);
194
300
  }
195
301
  let dormant = 0;
302
+ let completed = 0;
303
+ let filtered = 0;
196
304
  const items = [];
197
305
  for (const [workspaceId, workspace] of workspaces) {
198
- const current = (grouped.get(workspaceId) ?? []).filter(state => !isDormant(state, workspace, this.now()));
199
- if (!current.length) {
306
+ if (filters.repo && workspace.repo_name?.toLowerCase() !== filters.repo.toLowerCase()) {
307
+ filtered++;
308
+ continue;
309
+ }
310
+ if (wantedWorkspaceStatus && normalizedStatus(statusLabel(workspace)) !== wantedWorkspaceStatus) {
311
+ filtered++;
312
+ continue;
313
+ }
314
+ if (filters.prStatus && (workspace.pr_status ?? 'none') !== filters.prStatus) {
315
+ filtered++;
316
+ continue;
317
+ }
318
+ if ((!includeDone && isDone(workspace)) || (!includeMerged && isMerged(workspace))) {
319
+ completed++;
320
+ continue;
321
+ }
322
+ const live = (grouped.get(workspaceId) ?? []).filter(state => !isDormant(state, workspace, now, {
323
+ ignoreAge: ignoreDormantAge,
324
+ includeDone,
325
+ includedWorkspaceStatus: wantedWorkspaceStatus ?? undefined
326
+ }));
327
+ if (!live.length) {
200
328
  dormant++;
201
329
  continue;
202
330
  }
331
+ const current = live.filter(state => {
332
+ if (filters.agentStatus && agentStatus(state) !== filters.agentStatus)
333
+ return false;
334
+ const at = parseDate(state.updatedAt);
335
+ if (updatedSince !== null && at < updatedSince)
336
+ return false;
337
+ if (updatedBefore !== null && at >= updatedBefore)
338
+ return false;
339
+ return true;
340
+ });
341
+ if (!current.length) {
342
+ filtered++;
343
+ continue;
344
+ }
203
345
  current.sort((a, b) => overviewRank(a) - overviewRank(b) || parseDate(b.updatedAt) - parseDate(a.updatedAt));
204
346
  const state = current[0];
205
347
  const said = this.deps.reads.lastAssistantText(state.sessionId) ?? '';
206
348
  items.push({
207
349
  workspaceId,
208
350
  sessionId: state.sessionId,
209
- title: state.sessionTitle ? `${state.workspaceTitle}, ${state.sessionTitle}` : state.workspaceTitle,
351
+ title: oneLine(state.sessionTitle ? `${state.workspaceTitle}, ${state.sessionTitle}` : state.workspaceTitle, 100),
210
352
  status: overviewStatus(state, workspace),
211
353
  updatedAt: state.updatedAt,
212
354
  update: oneLine(speechText(said, 150), 150),
@@ -219,13 +361,18 @@ export class VoiceBriefBoard {
219
361
  const page = rankedPage.map(({ rank: _rank, ...item }) => item);
220
362
  const next = offset + page.length < items.length ? offset + page.length : null;
221
363
  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.'}`);
364
+ const lines = page.map(item => `${item.title} ${item.status}. ${spokenUpdateAge(item.updatedAt, now)}${item.update ? ` ${item.update}` : ' No agent update yet.'}`);
223
365
  const more = next === null ? '' : ` ${items.length - next} more current; ask me to continue.`;
224
- const none = items.length ? '' : ' Nothing is active right now.';
366
+ const none = items.length ? '' : filtered ? ' Nothing matches those filters.' : ' Nothing is active right now.';
367
+ const completedSummary = completed ? `, ${completed} completed hidden` : '';
368
+ const filteredSummary = filtered ? `, ${filtered} outside filters` : '';
225
369
  return {
226
- spoken: clipExact(`Fresh overview: ${items.length} current ${noun}, ${dormant} dormant. ${lines.join(' ')}${more}${none}`.trim(), 700),
370
+ spoken: clipExact(`Fresh overview: ${items.length} current ${noun}${completedSummary}${filteredSummary}, ${dormant} dormant. ${lines.join(' ')}${more}${none}`.trim(), 700),
371
+ asOf: new Date(now).toISOString(),
227
372
  current: items.length,
228
373
  dormant,
374
+ completed,
375
+ filtered,
229
376
  cursor: next,
230
377
  workspaces: page
231
378
  };
@@ -16,7 +16,9 @@ export class PreviewStore {
16
16
  return this.previews;
17
17
  try {
18
18
  const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
19
- this.previews = Array.isArray(parsed) ? parsed : [];
19
+ this.previews = Array.isArray(parsed)
20
+ ? parsed.map(preview => preview.kind ? preview : { ...preview, kind: 'send_prompt' })
21
+ : [];
20
22
  }
21
23
  catch {
22
24
  this.previews = [];
@@ -32,6 +34,7 @@ export class PreviewStore {
32
34
  const createdAt = this.now();
33
35
  const preview = {
34
36
  ...input,
37
+ kind: 'send_prompt',
35
38
  token: crypto.randomBytes(18).toString('base64url'),
36
39
  createdAt,
37
40
  expiresAt: createdAt + PREVIEW_TTL_MS,
@@ -44,7 +47,22 @@ export class PreviewStore {
44
47
  this.write();
45
48
  return preview;
46
49
  }
47
- claim(token, input) {
50
+ createWorkspace(input) {
51
+ const createdAt = this.now();
52
+ const preview = {
53
+ ...input,
54
+ kind: 'create_workspace',
55
+ token: crypto.randomBytes(18).toString('base64url'),
56
+ createdAt,
57
+ expiresAt: createdAt + PREVIEW_TTL_MS,
58
+ status: 'ready'
59
+ };
60
+ this.previews = this.read().filter(candidate => candidate.expiresAt >= createdAt);
61
+ this.previews.push(preview);
62
+ this.write();
63
+ return preview;
64
+ }
65
+ available(token, callId) {
48
66
  const preview = this.read().find(candidate => candidate.token === token);
49
67
  if (!preview)
50
68
  return { ok: false, reason: 'unknown' };
@@ -52,16 +70,41 @@ export class PreviewStore {
52
70
  return { ok: false, reason: 'expired' };
53
71
  if (preview.status !== 'ready')
54
72
  return { ok: false, reason: 'already-used' };
55
- if (preview.callId !== input.callId)
73
+ if (preview.callId !== callId)
56
74
  return { ok: false, reason: 'foreign-call' };
75
+ return { ok: true, preview };
76
+ }
77
+ markClaimed(preview) {
78
+ preview.status = 'claimed';
79
+ this.write();
80
+ return { ...preview };
81
+ }
82
+ claim(token, input) {
83
+ const found = this.available(token, input.callId);
84
+ if (!found.ok)
85
+ return found;
86
+ const preview = found.preview;
87
+ if (preview.kind !== 'send_prompt')
88
+ return { ok: false, reason: 'wrong-action' };
57
89
  if (preview.sessionId !== input.sessionId)
58
90
  return { ok: false, reason: 'foreign-session' };
59
91
  if (preview.text !== input.text)
60
92
  return { ok: false, reason: 'text-mismatch' };
61
93
  // Persist the claim before any caller starts an async UI delivery. A crash can lose a
62
94
  // send, but cannot replay one whose outcome became unknowable.
63
- preview.status = 'claimed';
64
- this.write();
65
- return { ok: true, preview: { ...preview } };
95
+ return { ok: true, preview: this.markClaimed(preview) };
96
+ }
97
+ claimWorkspace(token, input) {
98
+ const found = this.available(token, input.callId);
99
+ if (!found.ok)
100
+ return found;
101
+ const preview = found.preview;
102
+ if (preview.kind !== 'create_workspace')
103
+ return { ok: false, reason: 'wrong-action' };
104
+ if (preview.repo !== input.repo)
105
+ return { ok: false, reason: 'foreign-repo' };
106
+ if (preview.prompt !== input.prompt)
107
+ return { ok: false, reason: 'prompt-mismatch' };
108
+ return { ok: true, preview: this.markClaimed(preview) };
66
109
  }
67
110
  }
@@ -3,7 +3,9 @@ export const VOICE_INSTRUCTIONS = `You are a voice switchboard for the user's Co
3
3
 
4
4
  Start with voice_roll_call. Work through one decision at a time with voice_next_decision. Speak only the tool result's spoken field; never read ids, cursors, JSON keys, or tokens aloud. Keep replies short enough for someone walking.
5
5
 
6
- Every time the user asks for a workspace overview, status, progress, or what is happening across the fleet, call voice_workspace_overview starting at cursor zero. This is a fresh read, so never answer that request from the opening roll call or an earlier overview. If they ask to continue, pass the cursor returned by the prior overview.
6
+ Every time the user asks for a workspace overview, status, progress, or what is happening across the fleet, call voice_workspace_overview starting at cursor zero. This is a fresh read, so never answer from the opening roll call or an earlier overview. Merged and Done workspaces are hidden by default; include either only when the user asks for completed work or explicitly names that state. Translate time requests into updated_since or updated_before, and use repo, agent_status, workspace_status, or pr_status when requested. If they ask to continue, pass the prior cursor and the same filters.
7
+
8
+ When the user wants a new workspace, call voice_list_repos to resolve its exact repository, then voice_create_workspace_preview with the exact first prompt. Read the exact repository and prompt back and ask for yes. Only after yes, call voice_create_workspace with the returned token and unchanged repository and prompt. Creation runs asynchronously and its result will be announced.
7
9
 
8
10
  When the user wants to dispatch text, call voice_send_preview with the exact target and text. Read the exact preview back, including the target, and ask for an explicit yes. Only after yes, call voice_send with the returned token and exactly the same session and text. Never call voice_send without that confirmation. A send queues asynchronously; success is silent, while parked or failed delivery will be announced.
9
11
 
@@ -1,4 +1,4 @@
1
- import { oneLine } from "../speech.js";
1
+ import { clipExact, oneLine } from "../speech.js";
2
2
  /**
3
3
  * One definition table feeds both transports: SIP exposes it through the scoped
4
4
  * MCP server, while the PWA's private sideband exposes the same entries as
@@ -9,6 +9,9 @@ export const VOICE_TOOL_NAMES = [
9
9
  'voice_roll_call',
10
10
  'voice_workspace_overview',
11
11
  'voice_next_decision',
12
+ 'voice_list_repos',
13
+ 'voice_create_workspace_preview',
14
+ 'voice_create_workspace',
12
15
  'voice_send_preview',
13
16
  'voice_send'
14
17
  ];
@@ -20,11 +23,37 @@ export const VOICE_TOOL_DEFINITIONS = [
20
23
  },
21
24
  {
22
25
  name: 'voice_workspace_overview',
23
- description: 'Get a fresh overview of current workspaces with each latest agent update. Call this every time the user asks for an overview or workspace status, even if one was already given. Pass the returned cursor to continue.',
26
+ description: 'Get a fresh, dated overview of current workspaces with filters and the relay as-of time. Merged and Done workspaces are excluded unless explicitly included. Call this every time the user asks for an overview or workspace status, even if one was already given. Pass the same filters with the returned cursor to continue.',
24
27
  inputSchema: {
25
28
  type: 'object',
26
29
  properties: {
27
- cursor: { type: 'number', description: 'The cursor returned by the previous overview page; default 0.' }
30
+ cursor: { type: 'number', description: 'The cursor returned by the previous overview page; default 0.' },
31
+ repo: { type: 'string', description: 'Only this exact repository name.' },
32
+ agent_status: {
33
+ type: 'string',
34
+ enum: ['working', 'idle', 'error', 'needs-you'],
35
+ description: 'Only workspaces whose matching chat has this live agent status.'
36
+ },
37
+ workspace_status: {
38
+ type: 'string',
39
+ enum: ['backlog', 'in-progress', 'in-review', 'done', 'canceled'],
40
+ description: 'Only workspaces in this Conductor sidebar status. Requesting done includes Done workspaces.'
41
+ },
42
+ pr_status: {
43
+ type: 'string',
44
+ enum: ['merged', 'draft', 'conflicts', 'checks_failed', 'checks_pending', 'mergeable', 'none'],
45
+ description: 'Only workspaces with this pull-request status. Requesting merged includes merged workspaces.'
46
+ },
47
+ updated_since: {
48
+ type: 'string',
49
+ description: 'Only chat activity since today, yesterday, this-week, this-month, a relative duration like 24h or 7d, or an ISO date/time.'
50
+ },
51
+ updated_before: {
52
+ type: 'string',
53
+ description: 'Only chat activity before this exclusive named boundary, relative duration, ISO date/time, or date. For yesterday alone, use updated_since yesterday and updated_before today.'
54
+ },
55
+ include_done: { type: 'boolean', description: 'Include Done workspaces; default false.' },
56
+ include_merged: { type: 'boolean', description: 'Include merged workspaces; default false.' }
28
57
  }
29
58
  }
30
59
  },
@@ -42,6 +71,36 @@ export const VOICE_TOOL_DEFINITIONS = [
42
71
  }
43
72
  }
44
73
  },
74
+ {
75
+ name: 'voice_list_repos',
76
+ description: 'List the repositories where Conductor can create a workspace. Use before a creation preview.',
77
+ inputSchema: { type: 'object', properties: {} }
78
+ },
79
+ {
80
+ name: 'voice_create_workspace_preview',
81
+ description: 'Create a two-minute preview for a new workspace in an exact repository, with an optional first prompt. Speak its exact target and prompt and ask for yes before using voice_create_workspace.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ repo: { type: 'string', description: 'Exact repository name from voice_list_repos.' },
86
+ prompt: { type: 'string', description: 'Optional first prompt. Omit to create an empty workspace.' }
87
+ },
88
+ required: ['repo']
89
+ }
90
+ },
91
+ {
92
+ name: 'voice_create_workspace',
93
+ description: 'Create an exact workspace preview after the user said yes. Requires its token, repository, and unchanged prompt; never accepts raw unpreviewed creation.',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ token: { type: 'string' },
98
+ repo: { type: 'string' },
99
+ prompt: { type: 'string', description: 'The unchanged preview prompt; omit only when the preview was empty.' }
100
+ },
101
+ required: ['token', 'repo']
102
+ }
103
+ },
45
104
  {
46
105
  name: 'voice_send_preview',
47
106
  description: 'Create a two-minute exact-text send preview. Speak its exact target and text and ask for yes before using voice_send.',
@@ -84,23 +143,33 @@ function need(args, key) {
84
143
  throw new Error(`${key} is required`);
85
144
  return value.trim();
86
145
  }
146
+ function optional(args, key) {
147
+ const value = args[key];
148
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
149
+ }
87
150
  function answer(value) {
88
151
  return JSON.stringify(value);
89
152
  }
90
153
  function refusal(reason) {
91
154
  switch (reason) {
92
155
  case 'expired':
93
- return 'That preview expired. Read the exact target and text back again before sending.';
156
+ return 'That preview expired. Read the exact action back again before approving it.';
94
157
  case 'foreign-call':
95
158
  return 'That preview belongs to another call and cannot be used here.';
96
159
  case 'foreign-session':
97
160
  return 'That preview belongs to a different chat. Preview this target again.';
161
+ case 'foreign-repo':
162
+ return 'That preview belongs to a different repository. Preview this workspace again.';
98
163
  case 'text-mismatch':
99
164
  return 'The send text does not exactly match the preview. Preview the changed text first.';
165
+ case 'prompt-mismatch':
166
+ return 'The first prompt does not exactly match the preview. Preview the changed workspace first.';
167
+ case 'wrong-action':
168
+ return 'That preview is for a different action. Make a new preview first.';
100
169
  case 'already-used':
101
- return 'That preview was already used. The relay will not send it twice.';
170
+ return 'That preview was already used. The relay will not run it twice.';
102
171
  case 'unknown':
103
- return 'That preview token is unknown. Make a new preview before sending.';
172
+ return 'That preview token is unknown. Make a new preview before continuing.';
104
173
  }
105
174
  }
106
175
  function later(task) {
@@ -123,7 +192,30 @@ export function createVoiceTools(context) {
123
192
  ...definition('voice_workspace_overview'),
124
193
  run: async (args) => {
125
194
  const cursor = typeof args.cursor === 'number' && Number.isFinite(args.cursor) ? args.cursor : 0;
126
- return answer(await context.board.workspaceOverview(cursor));
195
+ const filters = {};
196
+ const repo = optional(args, 'repo');
197
+ const agentStatus = optional(args, 'agent_status');
198
+ const workspaceStatus = optional(args, 'workspace_status');
199
+ const prStatus = optional(args, 'pr_status');
200
+ const updatedSince = optional(args, 'updated_since');
201
+ const updatedBefore = optional(args, 'updated_before');
202
+ if (repo)
203
+ filters.repo = repo;
204
+ if (agentStatus)
205
+ filters.agentStatus = agentStatus;
206
+ if (workspaceStatus)
207
+ filters.workspaceStatus = workspaceStatus;
208
+ if (prStatus)
209
+ filters.prStatus = prStatus;
210
+ if (updatedSince)
211
+ filters.updatedSince = updatedSince;
212
+ if (updatedBefore)
213
+ filters.updatedBefore = updatedBefore;
214
+ if (typeof args.include_done === 'boolean')
215
+ filters.includeDone = args.include_done;
216
+ if (typeof args.include_merged === 'boolean')
217
+ filters.includeMerged = args.include_merged;
218
+ return answer(await context.board.workspaceOverview(cursor, filters));
127
219
  }
128
220
  },
129
221
  {
@@ -136,6 +228,71 @@ export function createVoiceTools(context) {
136
228
  return answer(next ?? { spoken: 'There are no more decisions in this call.', cursor, done: true });
137
229
  }
138
230
  },
231
+ {
232
+ ...definition('voice_list_repos'),
233
+ run: async () => {
234
+ const repos = context.listRepos();
235
+ return answer({
236
+ spoken: repos.length
237
+ ? clipExact(`Available repositories: ${repos.map(repo => repo.name).join(', ')}.`, 600)
238
+ : 'Conductor has no repository available for a new workspace.',
239
+ repos
240
+ });
241
+ }
242
+ },
243
+ {
244
+ ...definition('voice_create_workspace_preview'),
245
+ run: async (args) => {
246
+ const requestedRepo = need(args, 'repo');
247
+ const repo = context.listRepos().find(candidate => candidate.name.toLowerCase() === requestedRepo.toLowerCase());
248
+ if (!repo)
249
+ return answer({
250
+ status: 'refused',
251
+ spoken: `I could not find the ${oneLine(requestedRepo, 80)} repository. Ask me to list repositories first.`
252
+ });
253
+ const prompt = optional(args, 'prompt') ?? '';
254
+ const preview = context.previews.createWorkspace({ callId: context.callId, repo: repo.name, prompt });
255
+ const detail = prompt ? ` with this first prompt: “${oneLine(prompt, 220)}”` : ' with no first prompt.';
256
+ return answer({
257
+ status: 'preview',
258
+ token: preview.token,
259
+ repo: repo.name,
260
+ prompt,
261
+ spoken: `Create a new workspace in ${oneLine(repo.name, 80)}${detail} Say yes to create it.`
262
+ });
263
+ }
264
+ },
265
+ {
266
+ ...definition('voice_create_workspace'),
267
+ run: async (args) => {
268
+ const token = need(args, 'token');
269
+ const repo = need(args, 'repo');
270
+ const prompt = optional(args, 'prompt') ?? '';
271
+ const claimed = context.previews.claimWorkspace(token, { callId: context.callId, repo, prompt });
272
+ if (!claimed.ok)
273
+ return answer({ status: 'refused', spoken: refusal(claimed.reason) });
274
+ later(async () => {
275
+ try {
276
+ const result = await context.createWorkspace(claimed.preview);
277
+ if (!result.ok) {
278
+ await context.announce(`The new ${oneLine(repo, 80)} workspace was not created. ${oneLine(result.error ?? 'Try again later.', 220)}`);
279
+ return;
280
+ }
281
+ const created = prompt
282
+ ? `Created a new ${oneLine(repo, 80)} workspace and queued its first prompt.`
283
+ : `Created a new empty ${oneLine(repo, 80)} workspace.`;
284
+ await context.announce(result.warning ? `${created} ${oneLine(result.warning, 220)}` : created);
285
+ }
286
+ catch (error) {
287
+ await context.announce(`The new ${oneLine(repo, 80)} workspace was not created. ${oneLine(error instanceof Error ? error.message : String(error), 220)}`);
288
+ }
289
+ });
290
+ return answer({
291
+ status: 'queued',
292
+ spoken: `Creating a new workspace in ${oneLine(repo, 80)}. I will say when it is ready.`
293
+ });
294
+ }
295
+ },
139
296
  {
140
297
  ...definition('voice_send_preview'),
141
298
  run: async (args) => {