@vellumai/assistant 0.3.13 → 0.3.15

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.
Files changed (57) hide show
  1. package/ARCHITECTURE.md +17 -3
  2. package/Dockerfile +1 -1
  3. package/README.md +2 -0
  4. package/docs/architecture/scheduling.md +81 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
  7. package/src/__tests__/channel-policy.test.ts +19 -0
  8. package/src/__tests__/guardian-control-plane-policy.test.ts +582 -0
  9. package/src/__tests__/guardian-outbound-http.test.ts +8 -8
  10. package/src/__tests__/intent-routing.test.ts +22 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  12. package/src/__tests__/notification-routing-intent.test.ts +185 -0
  13. package/src/__tests__/recording-handler.test.ts +191 -31
  14. package/src/__tests__/recording-intent-fallback.test.ts +180 -0
  15. package/src/__tests__/recording-intent-handler.test.ts +597 -74
  16. package/src/__tests__/recording-intent.test.ts +738 -342
  17. package/src/__tests__/recording-state-machine.test.ts +1109 -0
  18. package/src/__tests__/reminder-store.test.ts +20 -18
  19. package/src/__tests__/reminder.test.ts +2 -1
  20. package/src/channels/config.ts +1 -1
  21. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
  22. package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
  23. package/src/config/system-prompt.ts +5 -0
  24. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  25. package/src/daemon/handlers/config-channels.ts +6 -6
  26. package/src/daemon/handlers/index.ts +1 -1
  27. package/src/daemon/handlers/misc.ts +258 -102
  28. package/src/daemon/handlers/recording.ts +417 -5
  29. package/src/daemon/handlers/sessions.ts +142 -68
  30. package/src/daemon/ipc-contract/computer-use.ts +23 -3
  31. package/src/daemon/ipc-contract/messages.ts +3 -1
  32. package/src/daemon/ipc-contract/shared.ts +6 -0
  33. package/src/daemon/ipc-contract-inventory.json +2 -0
  34. package/src/daemon/lifecycle.ts +2 -0
  35. package/src/daemon/recording-executor.ts +180 -0
  36. package/src/daemon/recording-intent-fallback.ts +132 -0
  37. package/src/daemon/recording-intent.ts +306 -15
  38. package/src/daemon/session-tool-setup.ts +4 -0
  39. package/src/memory/conversation-attention-store.ts +5 -5
  40. package/src/notifications/README.md +69 -1
  41. package/src/notifications/adapters/sms.ts +80 -0
  42. package/src/notifications/broadcaster.ts +1 -0
  43. package/src/notifications/copy-composer.ts +3 -3
  44. package/src/notifications/decision-engine.ts +70 -1
  45. package/src/notifications/decisions-store.ts +24 -0
  46. package/src/notifications/destination-resolver.ts +2 -1
  47. package/src/notifications/emit-signal.ts +35 -3
  48. package/src/notifications/signal.ts +6 -0
  49. package/src/notifications/types.ts +3 -0
  50. package/src/runtime/guardian-outbound-actions.ts +9 -9
  51. package/src/runtime/http-server.ts +7 -7
  52. package/src/runtime/routes/conversation-attention-routes.ts +3 -3
  53. package/src/runtime/routes/integration-routes.ts +5 -5
  54. package/src/schedule/scheduler.ts +15 -3
  55. package/src/tools/executor.ts +29 -0
  56. package/src/tools/guardian-control-plane-policy.ts +141 -0
  57. package/src/tools/types.ts +2 -0
@@ -21,10 +21,12 @@ import type {
21
21
  SuggestionRequest,
22
22
  TaskSubmit,
23
23
  } from '../ipc-protocol.js';
24
- import { classifyRecordingIntent, detectRecordingIntent, detectStopRecordingIntent, hasSubstantiveContent, isInterrogative, stripRecordingIntent, stripStopRecordingIntent } from '../recording-intent.js';
24
+ import { executeRecordingIntent } from '../recording-executor.js';
25
+ import { resolveRecordingIntent } from '../recording-intent.js';
26
+ import { classifyRecordingIntentFallback, containsRecordingKeywords } from '../recording-intent-fallback.js';
25
27
  import { buildSessionErrorMessage,classifySessionError } from '../session-error.js';
26
28
  import { handleCuSessionCreate } from './computer-use.js';
27
- import { handleRecordingStart, handleRecordingStop } from './recording.js';
29
+ import { handleRecordingPause, handleRecordingRestart, handleRecordingResume, handleRecordingStart, handleRecordingStop, isRecordingIdle } from './recording.js';
28
30
  import { defineHandlers, type HandlerContext,log, renderHistoryContent, wireEscalationHandler } from './shared.js';
29
31
 
30
32
  // ─── Task submit handler ────────────────────────────────────────────────────
@@ -70,125 +72,262 @@ export async function handleTaskSubmit(
70
72
  return;
71
73
  }
72
74
 
75
+ // ── Structured command intent (bypasses text parsing) ──────────────────
76
+ const config = getConfig();
77
+ if (config.daemon.standaloneRecording && msg.commandIntent?.domain === 'screen_recording') {
78
+ const action = msg.commandIntent.action;
79
+ rlog.info({ action, source: 'commandIntent' }, 'Recording command intent received');
80
+ if (action === 'start') {
81
+ const conversation = conversationStore.createConversation(msg.task || 'Screen Recording');
82
+ ctx.socketToSession.set(socket, conversation.id);
83
+ const recordingId = handleRecordingStart(conversation.id, { promptForSource: true }, socket, ctx);
84
+ ctx.send(socket, { type: 'task_routed', sessionId: conversation.id, interactionType: 'text_qa' });
85
+ ctx.send(socket, {
86
+ type: 'assistant_text_delta',
87
+ text: recordingId ? 'Starting screen recording.' : 'A recording is already active.',
88
+ sessionId: conversation.id,
89
+ });
90
+ ctx.send(socket, { type: 'message_complete', sessionId: conversation.id });
91
+ if (!recordingId) ctx.socketToSession.delete(socket);
92
+ return;
93
+ } else if (action === 'stop') {
94
+ let activeSessionId = ctx.socketToSession.get(socket);
95
+ if (!activeSessionId) {
96
+ const conversation = conversationStore.createConversation(msg.task || 'Stop Recording');
97
+ activeSessionId = conversation.id;
98
+ ctx.socketToSession.set(socket, activeSessionId);
99
+ }
100
+ const stopped = handleRecordingStop(activeSessionId, ctx) !== undefined;
101
+ ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
102
+ ctx.send(socket, {
103
+ type: 'assistant_text_delta',
104
+ text: stopped ? 'Stopping the recording.' : 'No active recording to stop.',
105
+ sessionId: activeSessionId,
106
+ });
107
+ ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
108
+ return;
109
+ } else if (action === 'restart') {
110
+ let activeSessionId = ctx.socketToSession.get(socket);
111
+ if (!activeSessionId) {
112
+ const conversation = conversationStore.createConversation(msg.task || 'Restart Recording');
113
+ activeSessionId = conversation.id;
114
+ ctx.socketToSession.set(socket, activeSessionId);
115
+ }
116
+ const restartResult = handleRecordingRestart(activeSessionId, socket, ctx);
117
+ ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
118
+ ctx.send(socket, {
119
+ type: 'assistant_text_delta',
120
+ text: restartResult.responseText,
121
+ sessionId: activeSessionId,
122
+ });
123
+ ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
124
+ return;
125
+ } else if (action === 'pause') {
126
+ let activeSessionId = ctx.socketToSession.get(socket);
127
+ if (!activeSessionId) {
128
+ const conversation = conversationStore.createConversation(msg.task || 'Pause Recording');
129
+ activeSessionId = conversation.id;
130
+ ctx.socketToSession.set(socket, activeSessionId);
131
+ }
132
+ const paused = handleRecordingPause(activeSessionId, ctx) !== undefined;
133
+ ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
134
+ ctx.send(socket, {
135
+ type: 'assistant_text_delta',
136
+ text: paused ? 'Pausing the recording.' : 'No active recording to pause.',
137
+ sessionId: activeSessionId,
138
+ });
139
+ ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
140
+ return;
141
+ } else if (action === 'resume') {
142
+ let activeSessionId = ctx.socketToSession.get(socket);
143
+ if (!activeSessionId) {
144
+ const conversation = conversationStore.createConversation(msg.task || 'Resume Recording');
145
+ activeSessionId = conversation.id;
146
+ ctx.socketToSession.set(socket, activeSessionId);
147
+ }
148
+ const resumed = handleRecordingResume(activeSessionId, ctx) !== undefined;
149
+ ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
150
+ ctx.send(socket, {
151
+ type: 'assistant_text_delta',
152
+ text: resumed ? 'Resuming the recording.' : 'No active recording to resume.',
153
+ sessionId: activeSessionId,
154
+ });
155
+ ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
156
+ return;
157
+ } else {
158
+ // Unrecognized action — fall through to normal text handling so the
159
+ // task is not silently dropped.
160
+ rlog.warn({ action, source: 'commandIntent' }, 'Unrecognized screen_recording action, falling through to text handling');
161
+ }
162
+ }
163
+
73
164
  // ── Standalone recording intent interception ──────────────────────────
74
- // Intercept recording-only and stop-recording prompts before they reach
75
- // the classifier. This prevents "record my screen" from creating a CU
76
- // session and routes it to the standalone recording flow instead.
77
- //
78
- // For mixed intent, recording start/stop is deferred until after the
79
- // downstream classifier creates the final conversation, so the recording
80
- // attachment is linked to the correct conversation (not an orphaned one).
81
165
  let pendingRecordingStart = false;
82
166
  let pendingRecordingStop = false;
83
- const config = getConfig();
167
+ let pendingRecordingRestart: 'restart_with_remainder' | 'start_and_stop_with_remainder' | false = false;
84
168
  if (config.daemon.standaloneRecording) {
85
169
  const name = getAssistantName();
86
170
  const dynamicNames = [name].filter(Boolean) as string[];
87
- const intentClass = classifyRecordingIntent(msg.task, dynamicNames);
88
-
89
- switch (intentClass) {
90
- case 'stop_only': {
91
- // Find the active session for this socket so we can resolve the
92
- // conversation that owns the recording.
93
- let activeSessionId = ctx.socketToSession.get(socket);
94
- // Ensure we have a sessionId for message_complete even if no prior session exists
95
- if (!activeSessionId) {
96
- const conversation = conversationStore.createConversation(msg.task);
97
- activeSessionId = conversation.id;
98
- ctx.socketToSession.set(socket, activeSessionId);
99
- }
100
- // Always attempt stop handleRecordingStop has a global fallback that
101
- // resolves to the active recording even if this conversation doesn't own it.
102
- const stopped = handleRecordingStop(activeSessionId, ctx) !== undefined;
103
- rlog.info('Recording stop intent intercepted');
104
- ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
105
- ctx.send(socket, {
106
- type: 'assistant_text_delta',
107
- text: stopped ? 'Stopping the recording.' : 'No active recording to stop.',
108
- sessionId: activeSessionId,
109
- });
110
- ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
111
- return;
171
+ const intentResult = resolveRecordingIntent(msg.task, dynamicNames);
172
+
173
+ if (intentResult.kind === 'start_only') {
174
+ // Create a conversation so the recording can be attached later
175
+ const conversation = conversationStore.createConversation(msg.task);
176
+ ctx.socketToSession.set(socket, conversation.id);
177
+
178
+ const execResult = executeRecordingIntent(intentResult, {
179
+ conversationId: conversation.id,
180
+ socket,
181
+ ctx,
182
+ });
183
+
184
+ ctx.send(socket, { type: 'task_routed', sessionId: conversation.id, interactionType: 'text_qa' });
185
+ ctx.send(socket, { type: 'assistant_text_delta', text: execResult.responseText!, sessionId: conversation.id });
186
+ ctx.send(socket, { type: 'message_complete', sessionId: conversation.id });
187
+
188
+ // If recording rejected, unbind socket
189
+ if (execResult.recordingStarted === false) {
190
+ ctx.socketToSession.delete(socket);
112
191
  }
113
- case 'start_only': {
114
- // Create a conversation so the recording can be attached later (M4).
192
+
193
+ rlog.info({ sessionId: conversation.id }, 'Recording-only intent intercepted routed to standalone recording');
194
+ return;
195
+ }
196
+
197
+ if (intentResult.kind === 'stop_only') {
198
+ let activeSessionId = ctx.socketToSession.get(socket);
199
+ if (!activeSessionId) {
115
200
  const conversation = conversationStore.createConversation(msg.task);
116
- ctx.socketToSession.set(socket, conversation.id);
201
+ activeSessionId = conversation.id;
202
+ ctx.socketToSession.set(socket, activeSessionId);
203
+ }
117
204
 
118
- const recordingId = handleRecordingStart(conversation.id, { promptForSource: true }, socket, ctx);
205
+ const execResult = executeRecordingIntent(intentResult, {
206
+ conversationId: activeSessionId,
207
+ socket,
208
+ ctx,
209
+ });
210
+
211
+ rlog.info('Recording stop intent intercepted');
212
+ ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
213
+ ctx.send(socket, { type: 'assistant_text_delta', text: execResult.responseText!, sessionId: activeSessionId });
214
+ ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
215
+ return;
216
+ }
119
217
 
120
- if (recordingId) {
121
- ctx.send(socket, { type: 'task_routed', sessionId: conversation.id, interactionType: 'text_qa' });
122
- ctx.send(socket, { type: 'assistant_text_delta', text: 'Starting screen recording.', sessionId: conversation.id });
123
- } else {
124
- // Recording was rejected (already active) — clean up the orphaned conversation
125
- ctx.send(socket, { type: 'task_routed', sessionId: conversation.id, interactionType: 'text_qa' });
126
- ctx.send(socket, { type: 'assistant_text_delta', text: 'A recording is already active.', sessionId: conversation.id });
127
- }
128
- ctx.send(socket, { type: 'message_complete', sessionId: conversation.id });
218
+ if (intentResult.kind === 'start_and_stop_only') {
219
+ let activeSessionId = ctx.socketToSession.get(socket);
220
+ if (!activeSessionId) {
221
+ const conversation = conversationStore.createConversation(msg.task);
222
+ activeSessionId = conversation.id;
223
+ ctx.socketToSession.set(socket, activeSessionId);
224
+ }
129
225
 
130
- if (!recordingId) {
131
- // Unbind the socket so the ephemeral rejection session doesn't block
132
- // future task_submit routing, but keep the conversation in the DB so
133
- // the client can still send follow-up messages to the routed sessionId.
134
- ctx.socketToSession.delete(socket);
135
- }
226
+ const execResult = executeRecordingIntent(intentResult, {
227
+ conversationId: activeSessionId,
228
+ socket,
229
+ ctx,
230
+ });
231
+
232
+ rlog.info('Recording start+stop intent intercepted');
233
+ ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
234
+ ctx.send(socket, { type: 'assistant_text_delta', text: execResult.responseText!, sessionId: activeSessionId });
235
+ ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
236
+ return;
237
+ }
136
238
 
137
- rlog.info({ sessionId: conversation.id }, 'Recording-only intent intercepted routed to standalone recording');
138
- return;
239
+ // Restart/pause/resumefully handled intents
240
+ if (intentResult.kind === 'restart_only' || intentResult.kind === 'pause_only' || intentResult.kind === 'resume_only') {
241
+ let activeSessionId = ctx.socketToSession.get(socket);
242
+ if (!activeSessionId) {
243
+ const conversation = conversationStore.createConversation(msg.task);
244
+ activeSessionId = conversation.id;
245
+ ctx.socketToSession.set(socket, activeSessionId);
139
246
  }
140
- case 'mixed': {
141
- // Skip recording side effects for questions about recording
142
- // (e.g., "how do I stop recording?") — let the model answer instead.
143
- if (isInterrogative(msg.task, dynamicNames)) {
144
- rlog.info('Mixed recording intent is interrogative — skipping side effects');
145
- break;
247
+
248
+ const execResult = executeRecordingIntent(intentResult, {
249
+ conversationId: activeSessionId,
250
+ socket,
251
+ ctx,
252
+ });
253
+
254
+ rlog.info({ kind: intentResult.kind }, 'Recording intent intercepted');
255
+ ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
256
+ ctx.send(socket, { type: 'assistant_text_delta', text: execResult.responseText!, sessionId: activeSessionId });
257
+ ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
258
+ return;
259
+ }
260
+
261
+ if (intentResult.kind === 'start_with_remainder' || intentResult.kind === 'stop_with_remainder' ||
262
+ intentResult.kind === 'start_and_stop_with_remainder' || intentResult.kind === 'restart_with_remainder') {
263
+ // Defer recording action until after classifier creates the final conversation
264
+ pendingRecordingStop = intentResult.kind === 'stop_with_remainder';
265
+ // start_and_stop_with_remainder is semantically a restart — route through
266
+ // handleRecordingRestart which properly cleans up maps between stop and start.
267
+ // However, when there's no active recording the stop is a no-op, so fall
268
+ // back to a plain start instead of restart.
269
+ if (intentResult.kind === 'start_and_stop_with_remainder') {
270
+ if (isRecordingIdle()) {
271
+ pendingRecordingStart = true;
272
+ } else {
273
+ pendingRecordingRestart = 'start_and_stop_with_remainder';
146
274
  }
275
+ } else {
276
+ pendingRecordingStart = intentResult.kind === 'start_with_remainder';
277
+ pendingRecordingRestart = intentResult.kind === 'restart_with_remainder' ? 'restart_with_remainder' : false;
278
+ }
279
+ (msg as { task: string }).task = intentResult.remainder;
280
+ rlog.info({ remaining: intentResult.remainder }, 'Recording intent deferred, continuing with remaining text');
281
+ }
147
282
 
148
- // Mixed = recording intent embedded in broader text (e.g., "open Chrome and record my screen").
149
- // Defer recording start/stop until after the classifier creates the final conversation,
150
- // so the recording attachment is linked to the correct conversation.
151
- const hasStart = detectRecordingIntent(msg.task);
152
- const hasStop = detectStopRecordingIntent(msg.task);
153
-
154
- // Strip recording clauses from the task
155
- let remaining = msg.task;
156
- if (hasStart) remaining = stripRecordingIntent(remaining);
157
- if (hasStop) remaining = stripStopRecordingIntent(remaining);
158
-
159
- // If nothing substantive remains after stripping, handle as recording-only now
160
- if (!hasSubstantiveContent(remaining, dynamicNames)) {
161
- let sessionId = ctx.socketToSession.get(socket);
162
- if (!sessionId) {
283
+ // 'none' deterministic resolver found nothing; try LLM fallback
284
+ // if the text contains recording-related keywords.
285
+ if (intentResult.kind === 'none' && containsRecordingKeywords(msg.task)) {
286
+ const fallback = await classifyRecordingIntentFallback(msg.task);
287
+ rlog.info({ fallbackAction: fallback.action, fallbackConfidence: fallback.confidence }, 'Recording intent LLM fallback result');
288
+
289
+ if (fallback.action !== 'none' && fallback.confidence === 'high') {
290
+ const kindMap: Record<string, import('../recording-intent.js').RecordingIntentResult> = {
291
+ start: { kind: 'start_only' },
292
+ stop: { kind: 'stop_only' },
293
+ restart: { kind: 'restart_only' },
294
+ pause: { kind: 'pause_only' },
295
+ resume: { kind: 'resume_only' },
296
+ };
297
+ const mapped = kindMap[fallback.action];
298
+ if (mapped) {
299
+ let activeSessionId = ctx.socketToSession.get(socket);
300
+ if (!activeSessionId) {
163
301
  const conversation = conversationStore.createConversation(msg.task);
164
- sessionId = conversation.id;
165
- ctx.socketToSession.set(socket, sessionId);
302
+ activeSessionId = conversation.id;
303
+ ctx.socketToSession.set(socket, activeSessionId);
166
304
  }
167
- if (hasStop) handleRecordingStop(sessionId, ctx);
168
- const startResult = hasStart ? handleRecordingStart(sessionId, { promptForSource: true }, socket, ctx) : null;
169
- ctx.send(socket, { type: 'task_routed', sessionId, interactionType: 'text_qa' });
170
- let text: string;
171
- if (hasStart && startResult) {
172
- text = hasStop ? 'Stopping current recording and starting a new one.' : 'Starting screen recording.';
173
- } else if (hasStart) {
174
- text = 'A recording is already active.';
175
- } else {
176
- text = 'Stopping the recording.';
305
+
306
+ const execResult = executeRecordingIntent(mapped, {
307
+ conversationId: activeSessionId,
308
+ socket,
309
+ ctx,
310
+ });
311
+
312
+ if (execResult.handled) {
313
+ rlog.info({ kind: mapped.kind, source: 'llm_fallback' }, 'Recording intent intercepted via LLM fallback');
314
+ ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
315
+ ctx.send(socket, {
316
+ type: 'assistant_text_delta',
317
+ text: execResult.responseText!,
318
+ sessionId: activeSessionId,
319
+ });
320
+ ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
321
+
322
+ // If recording was rejected (e.g. already active), unbind the
323
+ // socket so it doesn't stay bound to an orphaned conversation.
324
+ if (execResult.recordingStarted === false) {
325
+ ctx.socketToSession.delete(socket);
326
+ }
327
+ return;
177
328
  }
178
- ctx.send(socket, { type: 'assistant_text_delta', text, sessionId });
179
- ctx.send(socket, { type: 'message_complete', sessionId });
180
- return;
181
329
  }
182
-
183
- // Set deferred flags — recording will start after the final conversation is created
184
- pendingRecordingStart = hasStart;
185
- pendingRecordingStop = hasStop;
186
- (msg as { task: string }).task = remaining;
187
- rlog.info({ remaining }, 'Mixed recording intent — deferred, continuing with remaining text');
188
- break;
189
330
  }
190
- case 'none':
191
- break;
192
331
  }
193
332
  }
194
333
 
@@ -215,10 +354,20 @@ export async function handleTaskSubmit(
215
354
 
216
355
  // Start deferred recording from mixed intent (create a DB conversation
217
356
  // for the recording attachment since CU sessions don't have one).
218
- if (pendingRecordingStart || pendingRecordingStop) {
357
+ if (pendingRecordingStart || pendingRecordingStop || pendingRecordingRestart) {
219
358
  const recConversation = conversationStore.createConversation('Screen Recording');
220
359
  if (pendingRecordingStop) handleRecordingStop(recConversation.id, ctx);
221
360
  if (pendingRecordingStart) handleRecordingStart(recConversation.id, { promptForSource: true }, socket, ctx);
361
+ if (pendingRecordingRestart) {
362
+ const restartResult = handleRecordingRestart(recConversation.id, socket, ctx);
363
+ // TOCTOU: recording may have stopped between intent resolution and
364
+ // deferred execution. Fall back to plain start for stop-and-start
365
+ // intents (user wants a new recording), but not for pure restart.
366
+ if (!restartResult.initiated && restartResult.reason === 'no_active_recording'
367
+ && pendingRecordingRestart === 'start_and_stop_with_remainder') {
368
+ handleRecordingStart(recConversation.id, { promptForSource: true }, socket, ctx);
369
+ }
370
+ }
222
371
  }
223
372
 
224
373
  ctx.send(socket, {
@@ -243,6 +392,13 @@ export async function handleTaskSubmit(
243
392
  // Start deferred recording from mixed intent, now using the real conversation
244
393
  if (pendingRecordingStop) handleRecordingStop(conversation.id, ctx);
245
394
  if (pendingRecordingStart) handleRecordingStart(conversation.id, { promptForSource: true }, socket, ctx);
395
+ if (pendingRecordingRestart) {
396
+ const restartResult = handleRecordingRestart(conversation.id, socket, ctx);
397
+ if (!restartResult.initiated && restartResult.reason === 'no_active_recording'
398
+ && pendingRecordingRestart === 'start_and_stop_with_remainder') {
399
+ handleRecordingStart(conversation.id, { promptForSource: true }, socket, ctx);
400
+ }
401
+ }
246
402
 
247
403
  ctx.send(socket, {
248
404
  type: 'task_routed',