@vellumai/assistant 0.7.3 → 0.8.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.
Files changed (169) hide show
  1. package/ARCHITECTURE.md +29 -28
  2. package/Dockerfile +1 -0
  3. package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
  4. package/bun.lock +3 -0
  5. package/knip.json +1 -0
  6. package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
  7. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  8. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
  9. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
  11. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  12. package/openapi.yaml +22 -4
  13. package/package.json +3 -1
  14. package/src/__tests__/annotate-risk-options.test.ts +291 -0
  15. package/src/__tests__/approval-cascade.test.ts +8 -16
  16. package/src/__tests__/approval-routes-http.test.ts +6 -0
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +12 -25
  18. package/src/__tests__/call-constants.test.ts +10 -1
  19. package/src/__tests__/call-controller.test.ts +127 -0
  20. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
  21. package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
  22. package/src/__tests__/context-search-memory-source.test.ts +3 -26
  23. package/src/__tests__/context-search-pkb-source.test.ts +12 -6
  24. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -6
  25. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  26. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  27. package/src/__tests__/conversation-agent-loop.test.ts +3 -3
  28. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
  29. package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
  30. package/src/__tests__/conversation-process-callsite.test.ts +1 -6
  31. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -6
  32. package/src/__tests__/conversation-runtime-assembly.test.ts +15 -6
  33. package/src/__tests__/conversation-slash-unknown.test.ts +1 -6
  34. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +73 -1
  36. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +59 -0
  37. package/src/__tests__/conversation-workspace-injection.test.ts +1 -7
  38. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -7
  39. package/src/__tests__/filing-service.test.ts +2 -19
  40. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
  41. package/src/__tests__/injector-chain.test.ts +24 -16
  42. package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
  43. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
  44. package/src/__tests__/notification-decision-fallback.test.ts +91 -0
  45. package/src/__tests__/notification-decision-strategy.test.ts +22 -0
  46. package/src/__tests__/oauth-cli.test.ts +121 -0
  47. package/src/__tests__/relay-server.test.ts +46 -2
  48. package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
  49. package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
  50. package/src/__tests__/secret-response-routing.test.ts +7 -5
  51. package/src/__tests__/server-history-render.test.ts +82 -0
  52. package/src/__tests__/skill-include-graph.test.ts +31 -0
  53. package/src/__tests__/skill-load-tool.test.ts +44 -16
  54. package/src/__tests__/skills.test.ts +39 -0
  55. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
  56. package/src/__tests__/tool-executor.test.ts +155 -0
  57. package/src/__tests__/voice-session-bridge.test.ts +3 -0
  58. package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
  59. package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
  60. package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +15 -27
  61. package/src/agent/loop.ts +11 -0
  62. package/src/approvals/guardian-decision-primitive.ts +0 -13
  63. package/src/approvals/guardian-request-resolvers.ts +4 -32
  64. package/src/calls/call-constants.ts +5 -8
  65. package/src/calls/call-controller.ts +130 -67
  66. package/src/calls/relay-server.ts +7 -1
  67. package/src/calls/voice-session-bridge.ts +1 -1
  68. package/src/cli/commands/memory-v2.ts +7 -7
  69. package/src/cli/commands/oauth/__tests__/connect.test.ts +0 -254
  70. package/src/cli/commands/oauth/connect.ts +10 -52
  71. package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
  72. package/src/config/feature-flag-registry.json +1 -17
  73. package/src/config/loader.ts +72 -19
  74. package/src/config/schemas/memory-v2.ts +1 -1
  75. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
  76. package/src/daemon/conversation-agent-loop-handlers.ts +32 -0
  77. package/src/daemon/conversation-agent-loop.ts +13 -10
  78. package/src/daemon/conversation-lifecycle.ts +22 -8
  79. package/src/daemon/conversation-surfaces.ts +16 -14
  80. package/src/daemon/conversation-tool-setup.ts +9 -5
  81. package/src/daemon/conversation.ts +1 -1
  82. package/src/daemon/handlers/shared.ts +26 -0
  83. package/src/daemon/host-bash-proxy.ts +1 -1
  84. package/src/daemon/host-browser-proxy.ts +1 -1
  85. package/src/daemon/host-cu-proxy.ts +1 -1
  86. package/src/daemon/host-file-proxy.ts +1 -1
  87. package/src/daemon/host-transfer-proxy.ts +2 -2
  88. package/src/daemon/lifecycle.ts +88 -73
  89. package/src/daemon/memory-v2-startup.ts +55 -14
  90. package/src/daemon/message-types/messages.ts +19 -1
  91. package/src/documents/document-store.ts +35 -1
  92. package/src/filing/filing-service.ts +2 -3
  93. package/src/heartbeat/heartbeat-service.ts +1 -1
  94. package/src/ipc/assistant-server.ts +93 -36
  95. package/src/ipc/skill-server.ts +99 -42
  96. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +10 -57
  97. package/src/memory/context-search/sources/memory-v2.ts +1 -17
  98. package/src/memory/context-search/sources/memory.ts +2 -2
  99. package/src/memory/context-search/sources/pkb.ts +2 -3
  100. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +104 -61
  101. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
  102. package/src/memory/graph/conversation-graph-memory.ts +32 -9
  103. package/src/memory/graph/graph-search.test.ts +6 -5
  104. package/src/memory/graph/graph-search.ts +3 -4
  105. package/src/memory/graph/retriever.test.ts +12 -7
  106. package/src/memory/graph/retriever.ts +4 -5
  107. package/src/memory/graph/tool-handlers.ts +3 -4
  108. package/src/memory/graph/tools.ts +4 -4
  109. package/src/memory/indexer.ts +1 -2
  110. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
  111. package/src/memory/jobs/embed-concept-page.ts +223 -87
  112. package/src/memory/jobs-worker.ts +8 -4
  113. package/src/memory/pkb/pkb-search.test.ts +6 -5
  114. package/src/memory/pkb/pkb-search.ts +4 -5
  115. package/src/memory/qdrant-client.ts +3 -0
  116. package/src/memory/search/semantic.ts +4 -5
  117. package/src/memory/v2/__tests__/activation.test.ts +35 -5
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +21 -32
  119. package/src/memory/v2/__tests__/injection.test.ts +140 -23
  120. package/src/memory/v2/__tests__/qdrant.test.ts +310 -9
  121. package/src/memory/v2/__tests__/sim.test.ts +118 -7
  122. package/src/memory/v2/__tests__/static-context.test.ts +1 -13
  123. package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
  124. package/src/memory/v2/consolidation-job.ts +7 -8
  125. package/src/memory/v2/injection.ts +32 -12
  126. package/src/memory/v2/page-store.ts +39 -0
  127. package/src/memory/v2/prompts/consolidation.ts +5 -0
  128. package/src/memory/v2/qdrant.ts +209 -48
  129. package/src/memory/v2/sim.ts +67 -26
  130. package/src/memory/v2/static-context.ts +4 -8
  131. package/src/memory/v2/sweep-job.ts +5 -6
  132. package/src/memory/v2/types.ts +7 -0
  133. package/src/notifications/copy-composer.ts +46 -12
  134. package/src/notifications/decision-engine.ts +46 -0
  135. package/src/permissions/gateway-threshold-reader.ts +116 -8
  136. package/src/permissions/prompter.ts +86 -96
  137. package/src/permissions/secret-prompter.ts +31 -31
  138. package/src/plugins/defaults/injectors.ts +1 -2
  139. package/src/proactive-artifact/job.test.ts +51 -4
  140. package/src/proactive-artifact/job.ts +16 -2
  141. package/src/proactive-artifact/message-copy.ts +18 -1
  142. package/src/prompts/templates/SOUL.md +13 -28
  143. package/src/runtime/auth/route-policy.ts +1 -0
  144. package/src/runtime/channel-approvals.ts +3 -2
  145. package/src/runtime/guardian-reply-router.ts +0 -10
  146. package/src/runtime/pending-interactions.ts +19 -15
  147. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
  148. package/src/runtime/routes/approval-routes.ts +7 -3
  149. package/src/runtime/routes/consolidation-routes.ts +8 -9
  150. package/src/runtime/routes/conversation-query-routes.ts +44 -1
  151. package/src/runtime/routes/debug-bash-routes.ts +2 -0
  152. package/src/runtime/routes/filing-routes.ts +2 -3
  153. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +0 -3
  154. package/src/runtime/routes/memory-item-routes.test.ts +3 -9
  155. package/src/runtime/routes/memory-item-routes.ts +5 -6
  156. package/src/runtime/routes/memory-v2-routes.ts +103 -17
  157. package/src/skills/include-graph.ts +35 -13
  158. package/src/tools/document/document-tool.ts +20 -0
  159. package/src/tools/executor.ts +18 -2
  160. package/src/tools/memory/register.test.ts +7 -5
  161. package/src/tools/permission-checker.ts +15 -0
  162. package/src/tools/skills/load.ts +24 -20
  163. package/src/tools/tool-name-aliases.ts +19 -0
  164. package/src/tools/types.ts +19 -1
  165. package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +4 -62
  166. package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
  167. package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
  168. package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
  169. package/src/workspace/migrations/registry.ts +6 -0
@@ -32,6 +32,51 @@ export function nonEmpty(value: string | undefined): string | undefined {
32
32
  return trimmed.length > 0 ? trimmed : undefined;
33
33
  }
34
34
 
35
+ export function looksLikeIntermediaryInstruction(text: string): boolean {
36
+ const normalized = text.replace(/\s+/g, " ").trim();
37
+ const intermediaryAction =
38
+ "(?:tell|telling|ask|asking|remind|reminding|nudge|nudging|prompt|prompting|notify|notifying|encourage|encouraging|prime|priming|brief|briefing|coach|coaching)";
39
+ const target = "(?:the\\s+)?(?:guardian|recipient|user)";
40
+ return (
41
+ /\b(?:assistant|agent|system|model|watcher)\s+(?:should|needs?\s+to|must|can|could)\b/i.test(
42
+ normalized,
43
+ ) ||
44
+ new RegExp(
45
+ `\\b(?:consider|try|please)\\s+${intermediaryAction}\\s+${target}\\b`,
46
+ "i",
47
+ ).test(normalized) ||
48
+ new RegExp(
49
+ `\\b${intermediaryAction}\\s+${target}\\s+(?:to|that|about|with)\\b`,
50
+ "i",
51
+ ).test(normalized) ||
52
+ new RegExp(
53
+ `\\b${target}\\s+(?:should|needs?\\s+to|must|might\\s+want\\s+to)\\b`,
54
+ "i",
55
+ ).test(normalized) ||
56
+ new RegExp(`\\b(?:for|to)\\s+${target}\\s+to\\b`, "i").test(normalized)
57
+ );
58
+ }
59
+
60
+ function buildHeartbeatAlertCopy(
61
+ payload: Record<string, unknown>,
62
+ ): RenderedChannelCopy {
63
+ const summary = str(
64
+ payload.summary,
65
+ str(payload.body, "Your assistant found something worth your attention."),
66
+ ).trim();
67
+ const safePopupBody = looksLikeIntermediaryInstruction(summary)
68
+ ? "I found something worth your attention in a heartbeat check. Open the conversation for details."
69
+ : summary;
70
+
71
+ return {
72
+ title: str(payload.title, "Heartbeat Alert"),
73
+ body: safePopupBody,
74
+ deliveryText: safePopupBody,
75
+ conversationTitle: str(payload.conversationTitle, "Heartbeat"),
76
+ conversationSeedMessage: summary,
77
+ };
78
+ }
79
+
35
80
  // ── Access-request copy contract ─────────────────────────────────────────────
36
81
  //
37
82
  // Deterministic helpers for building guardian-facing access-request copy.
@@ -505,18 +550,7 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
505
550
  body: str(payload.body, "A watcher event requires your attention"),
506
551
  }),
507
552
 
508
- "heartbeat.alert": (payload) => {
509
- const body = str(
510
- payload.summary,
511
- str(payload.body, "Your assistant found something worth your attention."),
512
- );
513
- return {
514
- title: str(payload.title, "Heartbeat Alert"),
515
- body,
516
- conversationTitle: str(payload.conversationTitle, "Heartbeat"),
517
- conversationSeedMessage: body,
518
- };
519
- },
553
+ "heartbeat.alert": buildHeartbeatAlertCopy,
520
554
 
521
555
  "tool_confirmation.required_action": (payload) => ({
522
556
  title: "Tool Confirmation",
@@ -35,6 +35,7 @@ import {
35
35
  composeFallbackCopy,
36
36
  hasAccessRequestInstructions,
37
37
  hasInviteFlowDirective,
38
+ looksLikeIntermediaryInstruction,
38
39
  } from "./copy-composer.js";
39
40
  import { createDecision } from "./decisions-store.js";
40
41
  import {
@@ -127,11 +128,13 @@ function buildSystemPrompt(
127
128
  ``,
128
129
  `Copy guidelines (three distinct outputs):`,
129
130
  `- \`title\` and \`body\` are for native notification popups (e.g. vellum desktop/mobile) — keep them short and glanceable (title ≤ 8 words, body ≤ 2 sentences).`,
131
+ ` - Write popup copy as final copy for the guardian or recipient. Do not write instructions for the assistant or another intermediary.`,
130
132
  `- \`deliveryText\` is the channel-native message for chat channels (e.g. telegram). It must read naturally as a standalone message.`,
131
133
  ` - Do not prepend mechanical labels like "Conversation:".`,
132
134
  ` - Do not mention channel or transport names (e.g. Telegram, Slack, email) unless the event context explicitly requires it.`,
133
135
  ` - Do not repeat title/body verbatim unless that repetition is truly necessary.`,
134
136
  ` - Avoid meta-send phrasing (e.g. "I'd like to send a notification", "May I go ahead with that?"). Write the recipient-facing message directly.`,
137
+ ` - Avoid intermediary-instruction phrasing like "consider telling the guardian", "ask the recipient to", or "the assistant should remind them". Rewrite it as final copy the recipient can act on directly.`,
135
138
  ` - For telegram: 1-2 concise sentences.`,
136
139
  `- \`conversationSeedMessage\` is the opening message in the internal notification conversation — it can be richer and more contextual.`,
137
140
  ` - For vellum (desktop): 2-4 short sentences with useful context and clear next step if action is required.`,
@@ -664,6 +667,47 @@ function enforceAccessRequestInstructions(
664
667
  };
665
668
  }
666
669
 
670
+ function enforceHeartbeatAlertCopy(
671
+ decision: NotificationDecision,
672
+ signal: NotificationSignal,
673
+ ): NotificationDecision {
674
+ if (signal.sourceEventName !== "heartbeat.alert") return decision;
675
+ if (!decision.shouldNotify || decision.selectedChannels.length === 0)
676
+ return decision;
677
+
678
+ const fallbackCopy = composeFallbackCopy(signal, decision.selectedChannels);
679
+ const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
680
+ ...decision.renderedCopy,
681
+ };
682
+
683
+ for (const channel of decision.selectedChannels) {
684
+ const currentCopy = nextCopy[channel];
685
+ if (
686
+ currentCopy &&
687
+ !heartbeatCopyLooksLikeIntermediaryInstruction(currentCopy)
688
+ ) {
689
+ continue;
690
+ }
691
+ const safeCopy = fallbackCopy[channel];
692
+ if (!safeCopy) continue;
693
+ nextCopy[channel] = safeCopy;
694
+ }
695
+
696
+ return {
697
+ ...decision,
698
+ renderedCopy: nextCopy,
699
+ };
700
+ }
701
+
702
+ function heartbeatCopyLooksLikeIntermediaryInstruction(
703
+ copy: RenderedChannelCopy,
704
+ ): boolean {
705
+ return [copy.title, copy.body, copy.deliveryText].some(
706
+ (value) =>
707
+ typeof value === "string" && looksLikeIntermediaryInstruction(value),
708
+ );
709
+ }
710
+
667
711
  function ensureAccessRequestInstructionsInCopy(
668
712
  copy: RenderedChannelCopy,
669
713
  requestCode: string,
@@ -754,6 +798,7 @@ export async function evaluateSignal(
754
798
  let decision = buildFallbackDecision(signal, availableChannels);
755
799
  decision = enforceGuardianRequestCode(decision, signal);
756
800
  decision = enforceAccessRequestInstructions(decision, signal);
801
+ decision = enforceHeartbeatAlertCopy(decision, signal);
757
802
  decision = enforceGuardianCallConversationAffinity(decision, signal);
758
803
  decision = enforceConversationAffinity(
759
804
  decision,
@@ -783,6 +828,7 @@ export async function evaluateSignal(
783
828
 
784
829
  decision = enforceGuardianRequestCode(decision, signal);
785
830
  decision = enforceAccessRequestInstructions(decision, signal);
831
+ decision = enforceHeartbeatAlertCopy(decision, signal);
786
832
  decision = enforceGuardianCallConversationAffinity(decision, signal);
787
833
  decision = enforceConversationAffinity(
788
834
  decision,
@@ -44,6 +44,106 @@ const conversationThresholdCache = new Map<
44
44
  >();
45
45
  const CONVERSATION_CACHE_TTL_MS = 5_000;
46
46
 
47
+ // ── Failure-coalescing log helper ────────────────────────────────────────────
48
+ // When the gateway IPC socket is broken (e.g. the path was unlinked from
49
+ // disk), every threshold lookup fails with ENOENT on the hot path. Without
50
+ // coalescing the per-call WARN drowns the actual signal ("Strict-when-
51
+ // Relaxed because the gateway lost its socket") in its own log spam.
52
+ //
53
+ // Each `op` (e.g. "conversation_threshold", "global_thresholds") emits at
54
+ // most one WARN per {@link DEFAULT_FAILURE_WARN_INTERVAL_MS} window. The
55
+ // first failure in a streak WARNs immediately so failures aren't lost. When
56
+ // the IPC starts working again, an INFO records the streak duration and
57
+ // how many calls were swallowed — that's the cue dashboards should alert
58
+ // on.
59
+
60
+ interface FailureState {
61
+ consecutiveFailures: number;
62
+ firstFailureAt: number;
63
+ lastWarnAt: number;
64
+ }
65
+
66
+ const DEFAULT_FAILURE_WARN_INTERVAL_MS = 30_000;
67
+ let failureWarnIntervalMs = DEFAULT_FAILURE_WARN_INTERVAL_MS;
68
+ const failureStateByOp = new Map<string, FailureState>();
69
+
70
+ function noteFailure(
71
+ op: string,
72
+ fields: Record<string, unknown>,
73
+ message: string,
74
+ ): void {
75
+ const now = Date.now();
76
+ const state = failureStateByOp.get(op);
77
+ if (!state) {
78
+ failureStateByOp.set(op, {
79
+ consecutiveFailures: 1,
80
+ firstFailureAt: now,
81
+ lastWarnAt: now,
82
+ });
83
+ log.warn(
84
+ {
85
+ ...fields,
86
+ op,
87
+ consecutiveFailures: 1,
88
+ event: "ipc_threshold_failure",
89
+ },
90
+ message,
91
+ );
92
+ return;
93
+ }
94
+ state.consecutiveFailures += 1;
95
+ if (now - state.lastWarnAt >= failureWarnIntervalMs) {
96
+ log.warn(
97
+ {
98
+ ...fields,
99
+ op,
100
+ consecutiveFailures: state.consecutiveFailures,
101
+ streakDurationMs: now - state.firstFailureAt,
102
+ event: "ipc_threshold_failure",
103
+ },
104
+ message,
105
+ );
106
+ state.lastWarnAt = now;
107
+ }
108
+ }
109
+
110
+ function noteSuccess(op: string): void {
111
+ const state = failureStateByOp.get(op);
112
+ if (!state) return;
113
+ log.info(
114
+ {
115
+ op,
116
+ swallowedFailures: state.consecutiveFailures,
117
+ streakDurationMs: Date.now() - state.firstFailureAt,
118
+ event: "ipc_threshold_recovered",
119
+ },
120
+ "Gateway IPC threshold call recovered after failure streak",
121
+ );
122
+ failureStateByOp.delete(op);
123
+ }
124
+
125
+ /** Test-only: clear the failure-coalescing state. */
126
+ export function _resetFailureCoalesceForTesting(): void {
127
+ failureStateByOp.clear();
128
+ failureWarnIntervalMs = DEFAULT_FAILURE_WARN_INTERVAL_MS;
129
+ }
130
+
131
+ /**
132
+ * Test-only: read a snapshot of the failure-coalescing state for a given
133
+ * op. Returns `undefined` when no streak is in progress.
134
+ */
135
+ export function _getFailureStateForTesting(
136
+ op: string,
137
+ ): Readonly<FailureState> | undefined {
138
+ const state = failureStateByOp.get(op);
139
+ return state ? { ...state } : undefined;
140
+ }
141
+
142
+ /** Test-only: override the WARN cadence. Pass {@link DEFAULT_FAILURE_WARN_INTERVAL_MS} to reset. */
143
+ export function _setFailureWarnIntervalForTesting(intervalMs: number): void {
144
+ failureWarnIntervalMs = intervalMs;
145
+ }
146
+
47
147
  /**
48
148
  * Clear the global threshold cache. Exported for testing.
49
149
  */
@@ -112,18 +212,24 @@ export async function getAutoApproveThreshold(
112
212
  })) as ConversationThreshold | null | undefined;
113
213
 
114
214
  if (result === undefined) {
115
- log.warn(
215
+ noteFailure(
216
+ "conversation_threshold",
116
217
  { conversationId },
117
218
  "IPC call failed for conversation threshold override, falling through to global",
118
219
  );
119
220
  // Fall through to global threshold fetch below.
120
- } else if (result && isValidThreshold(result.threshold)) {
121
- conversationThresholdCache.set(conversationId, {
122
- threshold: result.threshold,
123
- timestamp: Date.now(),
124
- });
125
- return result.threshold;
126
221
  } else {
222
+ // Any defined response (including a null "no override") is a
223
+ // successful round-trip — clear any in-progress failure streak so
224
+ // dashboards see the recovery.
225
+ noteSuccess("conversation_threshold");
226
+ if (result && isValidThreshold(result.threshold)) {
227
+ conversationThresholdCache.set(conversationId, {
228
+ threshold: result.threshold,
229
+ timestamp: Date.now(),
230
+ });
231
+ return result.threshold;
232
+ }
127
233
  // result === null (or an unexpected shape) — cache the negative result
128
234
  // and fall through to global defaults.
129
235
  conversationThresholdCache.set(conversationId, {
@@ -151,7 +257,8 @@ export async function getAutoApproveThreshold(
151
257
  } catch (err) {
152
258
  // Gateway unreachable — default to "none" (Strict) so no tools are
153
259
  // silently auto-approved when the gateway is down.
154
- log.warn(
260
+ noteFailure(
261
+ "global_thresholds",
155
262
  { error: String(err) },
156
263
  "Failed to fetch global thresholds, defaulting to none",
157
264
  );
@@ -176,6 +283,7 @@ async function fetchGlobalThresholds(): Promise<GlobalThresholds> {
176
283
  throw new Error("Gateway IPC returned no result for global thresholds");
177
284
  }
178
285
 
286
+ noteSuccess("global_thresholds");
179
287
  cachedGlobalThresholds = result;
180
288
  cachedGlobalTimestamp = Date.now();
181
289
  return result;
@@ -11,19 +11,14 @@ import type { AllowlistOption, ScopeOption, UserDecision } from "./types.js";
11
11
 
12
12
  const log = getLogger("permission-prompter");
13
13
 
14
- interface PendingPrompt {
15
- resolve: (value: {
16
- decision: UserDecision;
17
- selectedPattern?: string;
18
- selectedScope?: string;
19
- decisionContext?: string;
20
- wasTimeout?: boolean;
21
- wasSystemCancel?: boolean;
22
- }) => void;
23
- reject: (reason: Error) => void;
24
- timer: ReturnType<typeof setTimeout>;
25
- toolUseId?: string;
26
- }
14
+ type ConfirmResult = {
15
+ decision: UserDecision;
16
+ selectedPattern?: string;
17
+ selectedScope?: string;
18
+ decisionContext?: string;
19
+ wasTimeout?: boolean;
20
+ wasSystemCancel?: boolean;
21
+ };
27
22
 
28
23
  export type ConfirmationStateCallback = (
29
24
  requestId: string,
@@ -33,7 +28,13 @@ export type ConfirmationStateCallback = (
33
28
  ) => void;
34
29
 
35
30
  export class PermissionPrompter {
36
- private pending = new Map<string, PendingPrompt>();
31
+ /**
32
+ * Tracks which requestIds belong to this prompter instance so that
33
+ * denyAllPending / dispose can scope their cleanup to this conversation.
34
+ * The full per-request state (callbacks, timer, toolUseId) lives in
35
+ * pendingInteractions, matching the host proxy pattern.
36
+ */
37
+ private ownedIds = new Set<string>();
37
38
  private sendToClient: (msg: ServerMessage) => void;
38
39
  private onStateChanged?: ConfirmationStateCallback;
39
40
 
@@ -69,74 +70,68 @@ export class PermissionPrompter {
69
70
  riskReason?: string,
70
71
  isContainerized?: boolean,
71
72
  directoryScopeOptions?: readonly { scope: string; label: string }[],
72
- ): Promise<{
73
- decision: UserDecision;
74
- selectedPattern?: string;
75
- selectedScope?: string;
76
- decisionContext?: string;
77
- wasTimeout?: boolean;
78
- wasSystemCancel?: boolean;
79
- wasAbort?: boolean;
80
- }> {
73
+ ): Promise<ConfirmResult & { wasAbort?: boolean }> {
81
74
  if (signal?.aborted) return { decision: "deny", wasAbort: true };
82
75
 
83
76
  const requestId = uuid();
84
77
 
85
- // Self-register in pendingInteractions so /v1/confirm can route the
86
- // response to this conversation without going through broadcastMessage.
87
- if (conversationId) {
88
- pendingInteractions.register(requestId, {
89
- conversationId,
90
- kind: "confirmation",
91
- confirmationDetails: {
92
- toolName,
93
- input: redactSensitiveFields(input),
94
- riskLevel,
95
- executionTarget,
96
- allowlistOptions: allowlistOptions.map((o) => ({
97
- label: o.label,
98
- description: o.description,
99
- pattern: o.pattern,
100
- })),
101
- scopeOptions: scopeOptions.map((o) => ({
102
- label: o.label,
103
- scope: o.scope,
104
- })),
105
- persistentDecisionsAllowed: persistentDecisionsAllowed ?? true,
106
- },
107
- });
108
- }
109
-
110
78
  return new Promise((resolve, reject) => {
111
79
  const timeoutMs = getConfig().timeouts.permissionTimeoutSec * 1000;
80
+
112
81
  const timer = setTimeout(() => {
113
- this.pending.delete(requestId);
114
- pendingInteractions.resolve(requestId);
82
+ const interaction = pendingInteractions.resolve(requestId);
83
+ this.ownedIds.delete(requestId);
115
84
  log.warn(
116
85
  { requestId, toolName },
117
86
  "Permission prompt timed out, defaulting to deny",
118
87
  );
119
88
  this.onStateChanged?.(requestId, "timed_out", "timeout", toolUseId);
120
- resolve({
121
- decision: "deny",
122
- wasTimeout: true,
123
- decisionContext: `The permission prompt for the "${toolName}" tool timed out. The user did not explicitly deny this request — they may have been away or busy. You may retry this tool call if it is still needed for the current task.`,
124
- });
89
+ (interaction?.rpcResolve as ((v: ConfirmResult) => void) | undefined)?.(
90
+ {
91
+ decision: "deny",
92
+ wasTimeout: true,
93
+ decisionContext: `The permission prompt for the "${toolName}" tool timed out. The user did not explicitly deny this request — they may have been away or busy. You may retry this tool call if it is still needed for the current task.`,
94
+ },
95
+ );
125
96
  }, timeoutMs);
126
97
 
127
- this.pending.set(requestId, {
128
- resolve,
129
- reject,
130
- timer,
131
- toolUseId,
132
- });
98
+ // Register all lifecycle state in pendingInteractions — same pattern as
99
+ // host proxies. The prompter tracks ownership via ownedIds.
100
+ // Always register unconditionally so rpcResolve/rpcReject/timer
101
+ // are reachable by resolveConfirmation, denyAllPending, and the timeout
102
+ // handler even when conversationId is absent. Routes return 404 for
103
+ // interactions with an empty conversationId, which is correct behaviour.
104
+ pendingInteractions.register(requestId, {
105
+ conversationId: conversationId ?? "",
106
+ kind: "confirmation",
107
+ confirmationDetails: {
108
+ toolName,
109
+ input: redactSensitiveFields(input),
110
+ riskLevel,
111
+ executionTarget,
112
+ allowlistOptions: allowlistOptions.map((o) => ({
113
+ label: o.label,
114
+ description: o.description,
115
+ pattern: o.pattern,
116
+ })),
117
+ scopeOptions: scopeOptions.map((o) => ({
118
+ label: o.label,
119
+ scope: o.scope,
120
+ })),
121
+ persistentDecisionsAllowed: persistentDecisionsAllowed ?? true,
122
+ },
123
+ rpcResolve: resolve as (value: unknown) => void,
124
+ rpcReject: reject,
125
+ timer,
126
+ toolUseId,
127
+ });
128
+ this.ownedIds.add(requestId);
133
129
 
134
130
  if (signal) {
135
131
  const onAbort = () => {
136
- if (this.pending.has(requestId)) {
137
- clearTimeout(timer);
138
- this.pending.delete(requestId);
132
+ if (this.ownedIds.has(requestId)) {
139
133
  pendingInteractions.resolve(requestId);
134
+ this.ownedIds.delete(requestId);
140
135
  resolve({ decision: "deny", wasAbort: true });
141
136
  }
142
137
  };
@@ -175,17 +170,17 @@ export class PermissionPrompter {
175
170
  }
176
171
 
177
172
  hasPendingRequest(requestId: string): boolean {
178
- return this.pending.has(requestId);
173
+ return this.ownedIds.has(requestId);
179
174
  }
180
175
 
181
176
  /** Returns all currently pending request IDs. */
182
177
  getPendingRequestIds(): string[] {
183
- return [...this.pending.keys()];
178
+ return [...this.ownedIds];
184
179
  }
185
180
 
186
181
  /** Returns the toolUseId associated with a pending request, if any. */
187
182
  getToolUseId(requestId: string): string | undefined {
188
- return this.pending.get(requestId)?.toolUseId;
183
+ return pendingInteractions.get(requestId)?.toolUseId;
189
184
  }
190
185
 
191
186
  resolveConfirmation(
@@ -195,22 +190,17 @@ export class PermissionPrompter {
195
190
  selectedScope?: string,
196
191
  decisionContext?: string,
197
192
  ): void {
198
- const pending = this.pending.get(requestId);
199
- if (!pending) {
193
+ if (!this.ownedIds.has(requestId)) {
200
194
  log.warn({ requestId }, "No pending prompt for confirmation response");
201
195
  return;
202
196
  }
203
- clearTimeout(pending.timer);
204
- this.pending.delete(requestId);
205
- // Idempotent approval-routes already calls pendingInteractions.resolve()
206
- // before routing here, but we call it defensively for non-route paths.
207
- pendingInteractions.resolve(requestId);
208
- pending.resolve({
209
- decision,
210
- selectedPattern,
211
- selectedScope,
212
- decisionContext,
213
- });
197
+ // The prompter owns deregistration; all callers use get() to peek before
198
+ // routing to resolveConfirmation, which fires the rpcResolve callback.
199
+ const interaction = pendingInteractions.resolve(requestId);
200
+ this.ownedIds.delete(requestId);
201
+ (interaction?.rpcResolve as ((v: ConfirmResult) => void) | undefined)?.(
202
+ { decision, selectedPattern, selectedScope, decisionContext },
203
+ );
214
204
  }
215
205
 
216
206
  /**
@@ -219,31 +209,31 @@ export class PermissionPrompter {
219
209
  * see the denial and can re-request if still needed.
220
210
  */
221
211
  denyAllPending(): void {
222
- for (const [requestId, pending] of this.pending) {
223
- clearTimeout(pending.timer);
224
- this.pending.delete(requestId);
225
- pendingInteractions.resolve(requestId);
226
- pending.resolve({
227
- decision: "deny",
228
- wasSystemCancel: true,
229
- decisionContext:
230
- "The user sent a new message instead of responding to this permission prompt. Stop what you are doing and respond to the user's new message. Do NOT retry this tool or request permission again until the user asks you to.",
231
- });
212
+ for (const requestId of [...this.ownedIds]) {
213
+ const interaction = pendingInteractions.resolve(requestId);
214
+ this.ownedIds.delete(requestId);
215
+ (interaction?.rpcResolve as ((v: ConfirmResult) => void) | undefined)?.(
216
+ {
217
+ decision: "deny",
218
+ wasSystemCancel: true,
219
+ decisionContext:
220
+ "The user sent a new message instead of responding to this permission prompt. Stop what you are doing and respond to the user's new message. Do NOT retry this tool or request permission again until the user asks you to.",
221
+ },
222
+ );
232
223
  }
233
224
  }
234
225
 
235
226
  get hasPending(): boolean {
236
- return this.pending.size > 0;
227
+ return this.ownedIds.size > 0;
237
228
  }
238
229
 
239
230
  dispose(): void {
240
- for (const [requestId, pending] of this.pending) {
241
- clearTimeout(pending.timer);
242
- pendingInteractions.resolve(requestId);
243
- pending.reject(
231
+ for (const requestId of [...this.ownedIds]) {
232
+ const interaction = pendingInteractions.resolve(requestId);
233
+ this.ownedIds.delete(requestId);
234
+ interaction?.rpcReject?.(
244
235
  new AssistantError("Prompter disposed", ErrorCode.INTERNAL_ERROR),
245
236
  );
246
237
  }
247
- this.pending.clear();
248
238
  }
249
239
  }