@vellumai/assistant 0.10.5-staging.1 → 0.10.5-staging.2

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.
@@ -23,6 +23,7 @@ import { extractPreferences } from "../notifications/preference-extractor.js";
23
23
  import { createPreference } from "../notifications/preferences-store.js";
24
24
  import {
25
25
  addMessage,
26
+ isHiddenMessageMetadata,
26
27
  provenanceFromTrustContext,
27
28
  setConversationOriginChannelIfUnset,
28
29
  setConversationOriginInterfaceIfUnset,
@@ -62,21 +63,29 @@ const log = getLogger("conversation-process");
62
63
 
63
64
  /**
64
65
  * Daemon-injected run lifecycle notifications — subagent (`subagentNotification`),
65
- * ACP run (`acpNotification`), and backgrounded bash/host_bash completion (the
66
- * `<background_event source="background-tool">` wake) — are persisted into the
67
- * parent conversation so the orchestrator wakes and reads the run's result, but
68
- * they are internal scaffolding: the user sees the run through its inline
69
- * progress card, not a chat turn. Skip the `user_message_echo` broadcast for
70
- * these so they never render as a live user bubble; the persisted row is
71
- * filtered from the rendered transcript on the client.
66
+ * ACP run (`acpNotification`), and any wake trigger (the persisted
67
+ * `<background_event source="...">` row every wake reads) — are persisted into
68
+ * the parent conversation so the orchestrator wakes and reads the trigger, but
69
+ * they are internal scaffolding: the user sees the wake through its inline card
70
+ * ("Conversation Woke", or a terminal card for a backgrounded bash run), not a
71
+ * chat turn. Skip the `user_message_echo` broadcast for these so they never
72
+ * render as a live user bubble; the persisted row is filtered from the rendered
73
+ * transcript on the client.
74
+ *
75
+ * Messages explicitly flagged `hidden` (a hidden `POST /messages` send that
76
+ * queued behind an in-flight turn, e.g. the channel-setup wizard-close
77
+ * marker) are suppressed the same way — the immediate route path already
78
+ * skips their echo, and the persisted `hidden` metadata keeps them out of
79
+ * the fetched transcript.
72
80
  */
73
- function isHiddenRunNotificationMessage(
81
+ function isEchoSuppressedUserMessage(
74
82
  metadata: Record<string, unknown> | undefined,
75
83
  ): boolean {
76
84
  return (
85
+ isHiddenMessageMetadata(metadata) ||
77
86
  metadata?.subagentNotification != null ||
78
87
  metadata?.acpNotification != null ||
79
- metadata?.backgroundEventSource === "background-tool"
88
+ typeof metadata?.backgroundEventSource === "string"
80
89
  );
81
90
  }
82
91
 
@@ -521,6 +530,7 @@ async function drainSingleMessage(
521
530
  }
522
531
  : {}),
523
532
  ...(next.metadata?.automated ? { automated: true } : {}),
533
+ ...(next.metadata?.hidden === true ? { hidden: true } : {}),
524
534
  ...(Object.keys(drainImageSourcePaths).length > 0
525
535
  ? { imageSourcePaths: drainImageSourcePaths }
526
536
  : {}),
@@ -879,7 +889,7 @@ async function drainSingleMessage(
879
889
 
880
890
  // Broadcast the user message to all hub subscribers so passive devices
881
891
  // see the user turn before the assistant reply starts streaming.
882
- if (!isHiddenRunNotificationMessage(next.metadata)) {
892
+ if (!isEchoSuppressedUserMessage(next.metadata)) {
883
893
  next.onEvent({
884
894
  type: "user_message_echo",
885
895
  text: resolvedContent,
@@ -897,7 +907,10 @@ async function drainSingleMessage(
897
907
 
898
908
  // Fire-and-forget: detect notification preferences in the queued message
899
909
  // and persist any that are found, mirroring the logic in processMessage.
900
- if (conversation.assistantId) {
910
+ // Hidden rows are machine signals, not user speech — running the detector
911
+ // on them burns an LLM call per signal and risks persisting a bogus
912
+ // preference from text the user never typed.
913
+ if (conversation.assistantId && !isHiddenMessageMetadata(next.metadata)) {
901
914
  extractPreferences(resolvedContent)
902
915
  .then((result) => {
903
916
  if (!result.detected) return;
@@ -932,11 +945,14 @@ async function drainSingleMessage(
932
945
  isInteractive?: boolean;
933
946
  isUserMessage?: boolean;
934
947
  titleText?: string;
948
+ isHiddenPrompt?: boolean;
935
949
  } = { isUserMessage: true };
936
950
  if (next.isInteractive !== undefined)
937
951
  drainLoopOptions.isInteractive = next.isInteractive;
938
952
  if (agentLoopContent !== resolvedContent)
939
953
  drainLoopOptions.titleText = resolvedContent;
954
+ if (isHiddenMessageMetadata(next.metadata))
955
+ drainLoopOptions.isHiddenPrompt = true;
940
956
 
941
957
  conversation
942
958
  .runAgentLoop(agentLoopContent, userMessageId, {
@@ -1233,7 +1249,7 @@ async function drainBatch(
1233
1249
 
1234
1250
  // Broadcast the user message to all hub subscribers so passive devices
1235
1251
  // see each batched user turn before the assistant reply starts streaming.
1236
- if (!isHiddenRunNotificationMessage(qm.metadata)) {
1252
+ if (!isEchoSuppressedUserMessage(qm.metadata)) {
1237
1253
  qm.onEvent({
1238
1254
  type: "user_message_echo",
1239
1255
  text: qmContent,
@@ -1254,8 +1270,9 @@ async function drainBatch(
1254
1270
  successfulBatch.push(qm);
1255
1271
 
1256
1272
  // Fire-and-forget: detect notification preferences in each batched user
1257
- // message and persist any that are found, mirroring drainSingleMessage.
1258
- if (conversation.assistantId) {
1273
+ // message and persist any that are found, mirroring drainSingleMessage
1274
+ // (including its hidden-row exclusion).
1275
+ if (conversation.assistantId && !isHiddenMessageMetadata(qm.metadata)) {
1259
1276
  extractPreferences(qmContent)
1260
1277
  .then((result) => {
1261
1278
  if (!result.detected) return;
@@ -1342,6 +1359,7 @@ async function drainBatch(
1342
1359
  isInteractive?: boolean;
1343
1360
  isUserMessage?: boolean;
1344
1361
  titleText?: string;
1362
+ isHiddenPrompt?: boolean;
1345
1363
  } = { isUserMessage: true };
1346
1364
  // Source interactive flag from the last successfully-persisted sibling so
1347
1365
  // a trailing failed tail doesn't flip the agent loop's interactivity.
@@ -1351,6 +1369,14 @@ async function drainBatch(
1351
1369
  : undefined;
1352
1370
  if (lastSuccessfulBatchEntry?.isInteractive !== undefined)
1353
1371
  drainLoopOptions.isInteractive = lastSuccessfulBatchEntry.isInteractive;
1372
+ // A batch counts as a hidden turn only when every message in it is a
1373
+ // hidden machine signal — one genuine user prompt justifies the
1374
+ // prompt-as-user-speech consumers (title generation).
1375
+ if (
1376
+ successfulBatch.length > 0 &&
1377
+ successfulBatch.every((qm) => isHiddenMessageMetadata(qm.metadata))
1378
+ )
1379
+ drainLoopOptions.isHiddenPrompt = true;
1354
1380
 
1355
1381
  // Fire-and-forget: runAgentLoop's finally block recursively calls drainQueue
1356
1382
  // when this run completes. Mirrors drainSingleMessage.
@@ -2105,6 +2105,8 @@ export class Conversation {
2105
2105
  isInteractive?: boolean;
2106
2106
  isUserMessage?: boolean;
2107
2107
  titleText?: string;
2108
+ /** See {@link runAgentLoopImpl} — hidden machine-signal turn marker. */
2109
+ isHiddenPrompt?: boolean;
2108
2110
  callSite?: LLMCallSite;
2109
2111
  /**
2110
2112
  * Optional ad-hoc inference-profile override applied to every LLM call
@@ -267,8 +267,9 @@ export async function persistWakeTailMessage(
267
267
  *
268
268
  * Mirrors {@link persistWakeTailMessage}'s channel/interface/provenance
269
269
  * metadata, but stamps `kind: "background-event"` (+ the originating `source`)
270
- * for identification, skips indexing (the body may carry untrusted command
271
- * output), and is NOT flagged hidden so the trigger shows in the transcript.
270
+ * for identification and skips indexing (the body may carry untrusted command
271
+ * output). The `backgroundEventSource` stamp lets clients hide this row from the
272
+ * rendered transcript — the user-facing wake card carries the status instead.
272
273
  */
273
274
  export async function persistWakeTriggerMessage(
274
275
  conversation: Conversation,
@@ -312,12 +313,10 @@ export async function persistWakeTriggerMessage(
312
313
  "wake trigger persist: syncMessageToDisk failed (non-fatal)",
313
314
  );
314
315
  }
315
- // Tell connected clients the message list changed so they refetch and the
316
- // visible trigger renders live. The normal user-send path publishes the same
317
- // invalidation after persisting a user message; without it a wake that
318
- // produces no assistant stream (silent no-op), or a conversation open in
319
- // another client, would not show the <background_event> row until a manual
320
- // reload.
316
+ // Tell connected clients the message list changed so they refetch. The
317
+ // trigger row itself is hidden from the transcript (clients drop rows carrying
318
+ // `backgroundEventSource`), but the refetch keeps other clients' state in sync
319
+ // and lets a wake that produces no assistant stream still settle cleanly.
321
320
  try {
322
321
  publishConversationMessagesChanged(conversation.conversationId);
323
322
  } catch (err) {
@@ -177,6 +177,16 @@ export const messageMetadataSchema = z
177
177
  provenanceGuardianExternalUserId: z.string().optional(),
178
178
  provenanceRequesterIdentifier: z.string().optional(),
179
179
  automated: z.boolean().optional(),
180
+ /**
181
+ * Transcript-suppression flag: the row is a machine signal (e.g. the
182
+ * channel-setup wizard-close marker, the onboarding greeting kickoff),
183
+ * persisted and LLM-visible but never rendered as a user message. Test
184
+ * with {@link isHiddenMessageMetadata} — hidden rows are filtered from
185
+ * list-messages and queued snapshots, skip the user_message_echo, and
186
+ * are excluded from search/memory indexing and other consumers that
187
+ * treat message text as organic user input.
188
+ */
189
+ hidden: z.boolean().optional(),
180
190
  /**
181
191
  * Structured terminal record stamped onto a `<background_event
182
192
  * source="background-tool">` wake so the web can rebuild the inline
@@ -212,6 +222,19 @@ export const messageMetadataSchema = z
212
222
  /** Validated shape of a persisted message's `metadata` column. */
213
223
  export type MessageMetadata = z.infer<typeof messageMetadataSchema>;
214
224
 
225
+ /**
226
+ * Shared predicate for the transcript-suppression flag on user-message
227
+ * metadata (see the `hidden` field on {@link messageMetadataSchema}). One
228
+ * definition so the sites that must agree — echo suppression, list-messages
229
+ * filtering, queued-snapshot filtering, indexing exclusion, and downstream
230
+ * consumers of message text — cannot drift.
231
+ */
232
+ export function isHiddenMessageMetadata(
233
+ metadata: Record<string, unknown> | null | undefined,
234
+ ): boolean {
235
+ return metadata?.hidden === true;
236
+ }
237
+
215
238
  /**
216
239
  * Parse a persisted message's metadata JSON against {@link messageMetadataSchema}
217
240
  * — the single source of truth for its shape — returning the validated fields,
@@ -1694,7 +1717,9 @@ export async function addMessage(
1694
1717
 
1695
1718
  const message = inserted;
1696
1719
 
1697
- if (!skipIndexing) {
1720
+ // Hidden rows are machine signals suppressed from the transcript — they
1721
+ // must not surface as search excerpts or be embedded into memory either.
1722
+ if (!skipIndexing && !isHiddenMessageMetadata(metadata)) {
1698
1723
  // Message-content lexical indexing is host infrastructure, invoked
1699
1724
  // directly (not through the memory hook seam, whose active side effects go
1700
1725
  // inert while the memory plugin is disabled — search indexing must not).
@@ -231,6 +231,13 @@ export interface UserPromptSubmitContext {
231
231
  * it from the message arrays.
232
232
  */
233
233
  readonly prompt: string;
234
+ /**
235
+ * True when the triggering message is a transcript-suppressed machine
236
+ * signal (`metadata.hidden` — e.g. the channel-setup wizard-close marker)
237
+ * rather than something the user typed. Hooks that treat `prompt` as
238
+ * user speech (e.g. title generation) should skip these turns.
239
+ */
240
+ readonly isHiddenPrompt?: boolean;
234
241
  /**
235
242
  * The user's original message list, immutable for the hook. Plugins
236
243
  * may snapshot or compare against this but MUST NOT mutate it.
@@ -19,6 +19,11 @@ import { getConversation } from "../../../../persistence/conversation-crud.js";
19
19
  import { queueGenerateConversationTitle } from "../../../../persistence/conversation-title-service.js";
20
20
 
21
21
  const userPromptSubmit: HookFunction<UserPromptSubmitContext> = async (ctx) => {
22
+ // Hidden machine signals (e.g. the channel-setup wizard-close marker) are
23
+ // not user speech — a title minted from one would surface invisible
24
+ // scaffolding text in the sidebar, and the LLM call is wasted.
25
+ if (ctx.isHiddenPrompt) return;
26
+
22
27
  // System conversations (background/scheduled) carry a deterministic title
23
28
  // from bootstrap. Their own job prompts arrive as non-interactive turns and
24
29
  // must not spend an LLM call on a title nobody reads — only a genuine user
@@ -117,6 +117,12 @@ export const conversationSummarySchema = z.object({
117
117
  */
118
118
  surfacedAt: z.number().optional(),
119
119
  inferenceProfile: z.string().optional(),
120
+ /**
121
+ * Plugin-id list scoping this chat to a subset of installed plugins.
122
+ * Absent when there is no per-chat restriction (default: all enabled
123
+ * plugins); an explicit `[]` means the user cleared all optional plugins.
124
+ */
125
+ enabledPlugins: z.array(z.string()).nullable().optional(),
120
126
  /**
121
127
  * True when the agent loop is currently mid-turn for this conversation.
122
128
  * Mirrors the in-memory `Conversation.isProcessing()` flag on the daemon
@@ -5,6 +5,7 @@
5
5
  * POST /v1/conversations/switch — switch to an existing conversation
6
6
  * POST /v1/conversations/fork — fork an existing conversation
7
7
  * PUT /v1/conversations/:id/inference-profile — set per-conversation inference profile
8
+ * PUT /v1/conversations/:id/enabledplugins — set per-conversation plugin scope
8
9
  * PATCH /v1/conversations/:id/name — rename a conversation
9
10
  * DELETE /v1/conversations — clear all conversations
10
11
  * POST /v1/conversations/:id/wipe — wipe conversation and revert memory
@@ -20,6 +21,7 @@
20
21
 
21
22
  import { z } from "zod";
22
23
 
24
+ import { findConversation } from "../../daemon/conversation-registry.js";
23
25
  import { destroyActiveConversation } from "../../daemon/conversation-store.js";
24
26
  import {
25
27
  cancelGeneration,
@@ -37,6 +39,7 @@ import {
37
39
  deleteConversation,
38
40
  forkConversation as forkConversationInStore,
39
41
  getConversation,
42
+ setConversationEnabledPlugins,
40
43
  setConversationSurfaced,
41
44
  unarchiveConversation,
42
45
  updateConversationTitle,
@@ -54,6 +57,7 @@ import { getLogger } from "../../util/logger.js";
54
57
  import { ACTOR_PRINCIPALS, LOCAL_PRINCIPALS } from "../auth/route-policy.js";
55
58
  import { buildConversationDetailResponse } from "../services/conversation-serializer.js";
56
59
  import {
60
+ publishConversationEnabledPluginsChanged,
57
61
  publishConversationListAndMetadataChanged,
58
62
  publishConversationListChanged,
59
63
  publishConversationTitleChanged,
@@ -222,6 +226,49 @@ async function handleSetInferenceProfile({
222
226
  return result;
223
227
  }
224
228
 
229
+ async function handleUpdateConversationEnabledPlugins({
230
+ pathParams = {},
231
+ body = {},
232
+ headers,
233
+ }: RouteHandlerArgs) {
234
+ const enabledPlugins = body.enabledPlugins as string[] | null | undefined;
235
+ // The field is required; `null` is the explicit "clear the scope" signal.
236
+ // A missing field (malformed/empty body) must not silently clear the scope.
237
+ if (enabledPlugins === undefined) {
238
+ throw new BadRequestError(
239
+ "enabledPlugins is required (use null to clear the scope)",
240
+ );
241
+ }
242
+ if (
243
+ enabledPlugins !== null &&
244
+ (!Array.isArray(enabledPlugins) ||
245
+ enabledPlugins.some((p) => typeof p !== "string"))
246
+ ) {
247
+ throw new BadRequestError(
248
+ "enabledPlugins must be an array of strings or null",
249
+ );
250
+ }
251
+
252
+ const resolvedId = resolveConversationId(pathParams.id!) ?? pathParams.id!;
253
+ const conversation = getConversation(resolvedId);
254
+ if (!conversation) {
255
+ throw new NotFoundError(`Conversation ${pathParams.id} not found`);
256
+ }
257
+
258
+ // `null` clears the per-chat scope back to the default (all enabled
259
+ // plugins); a `string[]` scopes the chat to those plugin ids.
260
+ const nextEnabledPlugins = enabledPlugins;
261
+ setConversationEnabledPlugins(resolvedId, nextEnabledPlugins);
262
+ findConversation(resolvedId)?.setEnabledPlugins(nextEnabledPlugins);
263
+
264
+ publishConversationEnabledPluginsChanged(
265
+ resolvedId,
266
+ headers?.["x-vellum-client-id"]?.trim() || undefined,
267
+ );
268
+
269
+ return { conversationId: resolvedId, enabledPlugins: nextEnabledPlugins };
270
+ }
271
+
225
272
  function handleRenameConversation({
226
273
  pathParams = {},
227
274
  body = {},
@@ -653,6 +700,30 @@ export const ROUTES: RouteDefinition[] = [
653
700
  }),
654
701
  handler: handleSetInferenceProfile,
655
702
  },
703
+ {
704
+ operationId: "conversations_by_id_enabledplugins_put",
705
+ endpoint: "conversations/:id/enabledplugins",
706
+ method: "PUT",
707
+ policy: {
708
+ requiredScopes: ["chat.write"],
709
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
710
+ },
711
+ summary: "Set conversation enabled plugins",
712
+ description:
713
+ "Scope a single conversation to a subset of installed plugins " +
714
+ "(first-party defaults are always available). Pass null to clear the " +
715
+ "scope back to the default (all enabled plugins).",
716
+ tags: ["conversations"],
717
+ pathParams: [{ name: "id", type: "uuid" }],
718
+ requestBody: z.object({
719
+ enabledPlugins: z.array(z.string()).nullable(),
720
+ }),
721
+ responseBody: z.object({
722
+ conversationId: z.string(),
723
+ enabledPlugins: z.array(z.string()).nullable(),
724
+ }),
725
+ handler: handleUpdateConversationEnabledPlugins,
726
+ },
656
727
  {
657
728
  operationId: "renameConversation",
658
729
  endpoint: "conversations/:id/name",
@@ -106,6 +106,7 @@ import {
106
106
  getMessagesPaginated,
107
107
  hasMessages,
108
108
  isConversationProcessing,
109
+ isHiddenMessageMetadata,
109
110
  type MessageRow,
110
111
  provenanceFromTrustContext,
111
112
  setConversationEnabledPlugins,
@@ -349,8 +350,9 @@ function emitCannedMessageComplete(
349
350
  function isHiddenMessage(metadata: string | null): boolean {
350
351
  if (!metadata) return false;
351
352
  try {
352
- const meta = JSON.parse(metadata) as { hidden?: unknown };
353
- return meta?.hidden === true;
353
+ return isHiddenMessageMetadata(
354
+ JSON.parse(metadata) as Record<string, unknown>,
355
+ );
354
356
  } catch {
355
357
  return false;
356
358
  }
@@ -633,43 +635,51 @@ function buildQueuedMessagePayloads(
633
635
  const conversation = findConversation(conversationId);
634
636
  if (!conversation) return [];
635
637
 
636
- return conversation.snapshotQueuedMessages().map((item, index) => {
637
- const text = item.displayContent ?? item.content;
638
- const attachments: RuntimeAttachmentMetadata[] = item.attachments.map(
639
- (a, idx) => ({
640
- id: a.id ?? `${item.requestId}:attachment:${idx}`,
641
- filename: a.filename,
642
- mimeType: a.mimeType,
643
- sizeBytes:
644
- a.sizeBytes ?? (a.data ? Math.floor((a.data.length * 3) / 4) : 0),
645
- kind: classifyKind(a.mimeType),
646
- ...(a.mimeType.startsWith("image/") && a.data ? { data: a.data } : {}),
647
- ...(a.thumbnailData ? { thumbnailData: a.thumbnailData } : {}),
648
- }),
649
- );
638
+ // Hidden sends are suppressed from the transcript at every stage — echo,
639
+ // persisted row, and here the in-memory queue window: a latest-page fetch
640
+ // while the item still awaits drain must not surface it as a queued bubble.
641
+ return conversation
642
+ .snapshotQueuedMessages()
643
+ .filter((item) => !isHiddenMessageMetadata(item.metadata))
644
+ .map((item, index) => {
645
+ const text = item.displayContent ?? item.content;
646
+ const attachments: RuntimeAttachmentMetadata[] = item.attachments.map(
647
+ (a, idx) => ({
648
+ id: a.id ?? `${item.requestId}:attachment:${idx}`,
649
+ filename: a.filename,
650
+ mimeType: a.mimeType,
651
+ sizeBytes:
652
+ a.sizeBytes ?? (a.data ? Math.floor((a.data.length * 3) / 4) : 0),
653
+ kind: classifyKind(a.mimeType),
654
+ ...(a.mimeType.startsWith("image/") && a.data
655
+ ? { data: a.data }
656
+ : {}),
657
+ ...(a.thumbnailData ? { thumbnailData: a.thumbnailData } : {}),
658
+ }),
659
+ );
650
660
 
651
- const contentBlocks: ConversationContentBlock[] = [];
652
- if (text.length > 0) contentBlocks.push({ type: "text", text });
653
- for (const attachment of attachments) {
654
- contentBlocks.push({ type: "attachment", attachment });
655
- }
661
+ const contentBlocks: ConversationContentBlock[] = [];
662
+ if (text.length > 0) contentBlocks.push({ type: "text", text });
663
+ for (const attachment of attachments) {
664
+ contentBlocks.push({ type: "attachment", attachment });
665
+ }
656
666
 
657
- return {
658
- // The queued message has no DB row yet; its requestId is the stable
659
- // identifier the queued-message delete/steer endpoints key on.
660
- id: item.requestId,
661
- role: "user" as const,
662
- content: text,
663
- timestamp: new Date(item.sentAt).toISOString(),
664
- attachments,
665
- ...(contentBlocks.length > 0 ? { contentBlocks } : {}),
666
- ...(item.clientMessageId
667
- ? { clientMessageId: item.clientMessageId }
668
- : {}),
669
- queueStatus: "queued" as const,
670
- queuePosition: index + 1,
671
- };
672
- });
667
+ return {
668
+ // The queued message has no DB row yet; its requestId is the stable
669
+ // identifier the queued-message delete/steer endpoints key on.
670
+ id: item.requestId,
671
+ role: "user" as const,
672
+ content: text,
673
+ timestamp: new Date(item.sentAt).toISOString(),
674
+ attachments,
675
+ ...(contentBlocks.length > 0 ? { contentBlocks } : {}),
676
+ ...(item.clientMessageId
677
+ ? { clientMessageId: item.clientMessageId }
678
+ : {}),
679
+ queueStatus: "queued" as const,
680
+ queuePosition: index + 1,
681
+ };
682
+ });
673
683
  }
674
684
 
675
685
  export function handleListMessages({
@@ -846,19 +856,19 @@ export function handleListMessages({
846
856
  }
847
857
  | undefined;
848
858
  let acpNotification: { acpSessionId: string; agent?: string } | undefined;
849
- let backgroundToolNotification: boolean | undefined;
859
+ let backgroundEventNotification: boolean | undefined;
850
860
  let backgroundToolCompletion: ConversationMessage["backgroundToolCompletion"];
851
861
  if (msg.metadata) {
852
862
  try {
853
863
  const meta = JSON.parse(msg.metadata);
854
864
  if (typeof meta.sentAt === "number") sentAt = meta.sentAt;
855
- // The backgrounded bash/host_bash completion wake persists a
856
- // `<background_event source="background-tool">` row (see
857
- // `persistWakeTriggerMessage`). Flag it so clients hide it from the
858
- // transcript like a subagent/ACP notification — the inline card carries
859
- // the status.
860
- if (meta.backgroundEventSource === "background-tool") {
861
- backgroundToolNotification = true;
865
+ // Every wake persists a `<background_event source="...">` trigger row
866
+ // (see `persistWakeTriggerMessage`) that the LLM reads. Flag any such
867
+ // row so clients hide it from the transcript like a subagent/ACP
868
+ // notification — the user-facing "Conversation Woke" card (or, for a
869
+ // backgrounded bash run, the inline terminal card) carries the status.
870
+ if (typeof meta.backgroundEventSource === "string") {
871
+ backgroundEventNotification = true;
862
872
  }
863
873
  // `persistWakeTriggerMessage` stamps the structured completion onto the
864
874
  // same wake row, letting the web rebuild a terminal inline card from
@@ -921,7 +931,7 @@ export function handleListMessages({
921
931
  sentAt,
922
932
  subagentNotification,
923
933
  acpNotification,
924
- backgroundToolNotification,
934
+ backgroundEventNotification,
925
935
  backgroundToolCompletion,
926
936
  slackMessage,
927
937
  clientMessageId: msg.clientMessageId ?? undefined,
@@ -1116,8 +1126,8 @@ export function handleListMessages({
1116
1126
  ? { subagentNotification: m.subagentNotification }
1117
1127
  : {}),
1118
1128
  ...(m.acpNotification ? { acpNotification: m.acpNotification } : {}),
1119
- ...(m.backgroundToolNotification
1120
- ? { backgroundToolNotification: true }
1129
+ ...(m.backgroundEventNotification
1130
+ ? { backgroundEventNotification: true }
1121
1131
  : {}),
1122
1132
  ...(m.backgroundToolCompletion
1123
1133
  ? { backgroundToolCompletion: m.backgroundToolCompletion }
@@ -1988,6 +1998,10 @@ export async function handleSendMessage(
1988
1998
  userMessageInterface: sourceInterface,
1989
1999
  assistantMessageInterface: sourceInterface,
1990
2000
  ...(body.automated === true ? { automated: true } : {}),
2001
+ // Carry the transcript-suppression flag through the queue so a
2002
+ // hidden send that lands mid-turn stays hidden when drained —
2003
+ // the drain path persists this metadata and skips the echo.
2004
+ ...(body.hidden === true ? { hidden: true } : {}),
1991
2005
  },
1992
2006
  isInteractive,
1993
2007
  sourceActorPrincipalId,
@@ -2009,21 +2023,34 @@ export async function handleSendMessage(
2009
2023
  // here must not turn the 202 response into a 500 — that would leave
2010
2024
  // the client showing "Failed to send" for a message the daemon will
2011
2025
  // process from the queue.
2012
- try {
2013
- // Supersede interactions left pending by the in-flight turn: auto-deny
2014
- // confirmations (with canonical/client sync) and steer to the enqueued
2015
- // message if an ask_question is parked. Centralized so the CLI signal
2016
- // path (signals/user-message.ts) gets identical handling.
2017
- supersedePendingInteractionsOnEnqueue(mapping.conversationId, requestId);
2018
-
2019
- // Expire any orphaned canonical requests that survived without a
2020
- // matching in-memory pending interaction (e.g. prompter timeouts).
2021
- expireOrphanedCanonicalRequests(mapping.conversationId);
2022
- } catch (err) {
2023
- log.warn(
2024
- { err, conversationId: mapping.conversationId },
2025
- "Post-enqueue auto-deny failed queued message unaffected",
2026
- );
2026
+ //
2027
+ // Supersede encodes user intent a typed message while a prompt is open
2028
+ // means the user chose to move on. A hidden send is a machine signal
2029
+ // (e.g. the channel-setup wizard-close marker), not a user decision: it
2030
+ // must not auto-deny live approval prompts or steer a parked
2031
+ // ask_question to a message the user never typed. Daemon-injected
2032
+ // synthetic messages (subagent/ACP notifications) skip this path the
2033
+ // same way by enqueuing directly.
2034
+ if (body.hidden !== true) {
2035
+ try {
2036
+ // Supersede interactions left pending by the in-flight turn: auto-deny
2037
+ // confirmations (with canonical/client sync) and steer to the enqueued
2038
+ // message if an ask_question is parked. Centralized so the CLI signal
2039
+ // path (signals/user-message.ts) gets identical handling.
2040
+ supersedePendingInteractionsOnEnqueue(
2041
+ mapping.conversationId,
2042
+ requestId,
2043
+ );
2044
+
2045
+ // Expire any orphaned canonical requests that survived without a
2046
+ // matching in-memory pending interaction (e.g. prompter timeouts).
2047
+ expireOrphanedCanonicalRequests(mapping.conversationId);
2048
+ } catch (err) {
2049
+ log.warn(
2050
+ { err, conversationId: mapping.conversationId },
2051
+ "Post-enqueue auto-deny failed — queued message unaffected",
2052
+ );
2053
+ }
2027
2054
  }
2028
2055
 
2029
2056
  return {
@@ -2039,7 +2066,11 @@ export async function handleSendMessage(
2039
2066
  // before dispatching, so an idle conversation with lingering confirmations
2040
2067
  // (e.g. the user never responded to a tool-approval prompt) must deny
2041
2068
  // them before starting the new turn.
2042
- if (conversation.hasAnyPendingConfirmation()) {
2069
+ // Hidden sends are machine signals, not user decisions — like the queue
2070
+ // branch's supersede bypass above, they must not deny confirmations that
2071
+ // outlived a turn (e.g. a guardian approval still awaiting a channel
2072
+ // reply). The next visible send performs the cleanup instead.
2073
+ if (body.hidden !== true && conversation.hasAnyPendingConfirmation()) {
2043
2074
  for (const interaction of pendingInteractions.getByConversation(
2044
2075
  mapping.conversationId,
2045
2076
  )) {
@@ -2438,6 +2469,7 @@ export async function handleSendMessage(
2438
2469
  onEvent: broadcastMessage,
2439
2470
  isInteractive,
2440
2471
  isUserMessage: true,
2472
+ ...(body.hidden === true ? { isHiddenPrompt: true } : {}),
2441
2473
  })
2442
2474
  .catch((err) => {
2443
2475
  log.error(
@@ -2953,7 +2985,7 @@ export const ROUTES: RouteDefinition[] = [
2953
2985
  .boolean()
2954
2986
  .optional()
2955
2987
  .describe(
2956
- "When true, persist the user message but suppress it from the UI transcript (it stays in LLM-side history and still drives the turn). Used to prime a proactive assistant greeting without showing the triggering user message. Honored on the standard send path only.",
2988
+ "When true, persist the user message but suppress it from the UI transcript (it stays in LLM-side history and still drives the turn). Used for machine signals the user never typed (proactive-greeting priming, channel-setup wizard close). Suppression covers the queued path too: a hidden send that lands mid-turn returns { queued: true, requestId } but never appears in list-messages queued snapshots, emits no echo, and does not supersede pending interactions. Honored on the standard send path only — slash-command content bypasses it.",
2957
2989
  ),
2958
2990
  onboarding: z
2959
2991
  .object({