@vellumai/assistant 0.11.5 → 0.11.6-staging.1

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 (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Level-based reconciler converging `app_pins` onto the apps that exist.
3
+ *
4
+ * A workspace app's id is a UUID, so a pin left behind when one is deleted can
5
+ * never be adopted and the app list simply never returns it. A plugin app's id
6
+ * is a path identity, `plugins~<plugin>~<appDir>`, and `listPluginApps` omits a
7
+ * plugin that is disabled or uninstalled. Retiring one and bringing it back
8
+ * rebuilds the same id, so a pin left behind returns to the sidebar with it,
9
+ * for an app the user had no way to unpin while it was hidden. Converging on
10
+ * every plugin-set change is what keeps a retirement from being undone.
11
+ *
12
+ * Triggers mirror `schedule/plugin-schedule-reconciler.ts`, which solves the
13
+ * same problem for plugin-declared schedules: daemon startup
14
+ * (`daemon/lifecycle.ts`), plugin-set convergence (`plugins/mtime-cache.ts`),
15
+ * and a periodic backstop sweep (`runtime/http-server.ts`) that covers the
16
+ * `.disabled` sentinel, which the CLI flips out of process without poking the
17
+ * source reconcile.
18
+ *
19
+ * Self-contained in the same way: it never throws, and it checks database
20
+ * readiness itself rather than relying on its callers to be ordered after
21
+ * migrations.
22
+ */
23
+
24
+ import { getDbMigrationReadiness } from "../daemon/daemon-readiness.js";
25
+ import { publishAppsChanged } from "../runtime/sync/resource-sync-events.js";
26
+ import { getLogger } from "../util/logger.js";
27
+ import { listAppPins, removeAppPin } from "./app-pin-store.js";
28
+ import { listApps, listPluginApps } from "./app-store.js";
29
+
30
+ const log = getLogger("app-pin-reconciler");
31
+
32
+ /** How often the backstop sweep converges, matching the schedule reconciler. */
33
+ const SWEEP_INTERVAL_MS = 60_000;
34
+
35
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
36
+ let sweepInProgress = false;
37
+
38
+ /** Drop every pin whose app is not currently installed. Never throws. */
39
+ export function reconcileAppPins(): void {
40
+ if (!getDbMigrationReadiness().ready) {
41
+ return;
42
+ }
43
+ try {
44
+ const live = new Set([
45
+ ...listApps().map((app) => app.id),
46
+ ...listPluginApps().map((app) => app.id),
47
+ ]);
48
+ const stale = listAppPins().filter((pin) => !live.has(pin.appId));
49
+ for (const pin of stale) {
50
+ removeAppPin(pin.appId);
51
+ }
52
+ if (stale.length > 0) {
53
+ log.info(
54
+ { removed: stale.map((pin) => pin.appId) },
55
+ "Dropped pins for apps that are no longer installed",
56
+ );
57
+ /* A pass has no originating client, and every other pin write announces
58
+ itself, so a client would otherwise keep rendering a pin this just
59
+ deleted until some unrelated refetch. */
60
+ publishAppsChanged();
61
+ }
62
+ } catch (err) {
63
+ log.error({ err }, "app pin reconcile failed");
64
+ }
65
+ }
66
+
67
+ /** Start the periodic backstop sweep. Idempotent. */
68
+ export function startAppPinReconcileSweep(): void {
69
+ if (sweepTimer) {
70
+ return;
71
+ }
72
+ sweepTimer = setInterval(() => {
73
+ if (sweepInProgress) {
74
+ return;
75
+ }
76
+ sweepInProgress = true;
77
+ try {
78
+ reconcileAppPins();
79
+ } finally {
80
+ sweepInProgress = false;
81
+ }
82
+ }, SWEEP_INTERVAL_MS);
83
+ }
84
+
85
+ /** Stop the periodic sweep. Used in tests and shutdown. */
86
+ export function stopAppPinReconcileSweep(): void {
87
+ if (sweepTimer) {
88
+ clearInterval(sweepTimer);
89
+ sweepTimer = null;
90
+ }
91
+ sweepInProgress = false;
92
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Which apps the user pinned to the sidebar, and in what order.
3
+ *
4
+ * Rows hold ids alone. Name and icon are read from the app itself at list
5
+ * time, so a renamed app renames its pin, and a pin whose app is gone surfaces
6
+ * nowhere.
7
+ */
8
+
9
+ import { eq, max } from "drizzle-orm";
10
+
11
+ import { getDb } from "../persistence/db-connection.js";
12
+ import { appPins } from "../persistence/schema/index.js";
13
+
14
+ export interface AppPin {
15
+ appId: string;
16
+ /** Fractional index. Ascending is sidebar order; the values are not dense. */
17
+ sortPosition: number;
18
+ /** An id from the client's pinned-app colour registry. Absent when unset. */
19
+ color?: string;
20
+ }
21
+
22
+ function toPin(row: {
23
+ appId: string;
24
+ sortPosition: number;
25
+ color: string | null;
26
+ }): AppPin {
27
+ return {
28
+ appId: row.appId,
29
+ sortPosition: row.sortPosition,
30
+ ...(row.color === null ? {} : { color: row.color }),
31
+ };
32
+ }
33
+
34
+ /** Every pin, in sidebar order. */
35
+ export function listAppPins(): AppPin[] {
36
+ return getDb()
37
+ .select()
38
+ .from(appPins)
39
+ .orderBy(appPins.sortPosition)
40
+ .all()
41
+ .map(toPin);
42
+ }
43
+
44
+ function getAppPin(appId: string): AppPin | null {
45
+ const row = getDb()
46
+ .select()
47
+ .from(appPins)
48
+ .where(eq(appPins.appId, appId))
49
+ .get();
50
+ return row ? toPin(row) : null;
51
+ }
52
+
53
+ /** One past the last pin, so pinning appends. */
54
+ function nextSortPosition(): number {
55
+ const highest = getDb()
56
+ .select({ value: max(appPins.sortPosition) })
57
+ .from(appPins)
58
+ .get();
59
+ return (highest?.value ?? 0) + 1;
60
+ }
61
+
62
+ export interface AppPinUpdate {
63
+ /** Pin or unpin. Omitted leaves the app's pinned state as it stands. */
64
+ pinned?: boolean;
65
+ /** Set (string) or clear (`null`) the colour. Omitted leaves it as it stands. */
66
+ color?: string | null;
67
+ }
68
+
69
+ /**
70
+ * Apply a pin change and return the app's resulting pin, or `null` once it is
71
+ * no longer pinned.
72
+ *
73
+ * A colour for an app that is not pinned is dropped, so a colour can never
74
+ * conjure a pin that unpinning just removed.
75
+ */
76
+ export function updateAppPin(
77
+ appId: string,
78
+ update: AppPinUpdate,
79
+ ): AppPin | null {
80
+ if (update.pinned === false) {
81
+ removeAppPin(appId);
82
+ return null;
83
+ }
84
+
85
+ const existing = getAppPin(appId);
86
+ if (!existing && update.pinned !== true) {
87
+ return null;
88
+ }
89
+
90
+ const color = update.color === undefined ? existing?.color : update.color;
91
+ const db = getDb();
92
+ const sortPosition = existing?.sortPosition ?? nextSortPosition();
93
+
94
+ if (existing) {
95
+ db.update(appPins)
96
+ .set({ color: color ?? null })
97
+ .where(eq(appPins.appId, appId))
98
+ .run();
99
+ } else {
100
+ /* `onConflictDoUpdate` rather than a plain insert: two clients pinning the
101
+ same app at once would otherwise race on the primary key. */
102
+ db.insert(appPins)
103
+ .values({
104
+ appId,
105
+ sortPosition,
106
+ color: color ?? null,
107
+ createdAt: Date.now(),
108
+ })
109
+ .onConflictDoUpdate({
110
+ target: appPins.appId,
111
+ set: { color: color ?? null },
112
+ })
113
+ .run();
114
+ }
115
+
116
+ return { appId, sortPosition, ...(color == null ? {} : { color }) };
117
+ }
118
+
119
+ /**
120
+ * Drop an app's pin as part of deleting it, so the pin does not outlive the app
121
+ * it points at. A no-op when the app was not pinned.
122
+ */
123
+ export function removeAppPin(appId: string): void {
124
+ getDb().delete(appPins).where(eq(appPins.appId, appId)).run();
125
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Daemon-side reader for whether a socket-backed channel is receiving.
3
+ *
4
+ * The gateway holds the inbound sockets; the readiness surface that reports
5
+ * channel health to the user lives here. This is the relay between them.
6
+ */
7
+
8
+ import {
9
+ type ChannelSocketHealthIpcParams,
10
+ type ChannelSocketHealthIpcResponse,
11
+ ChannelSocketHealthIpcResponseSchema,
12
+ } from "@vellumai/gateway-client/gateway-ipc-contracts";
13
+
14
+ import { ipcCallPersistentValidated } from "../ipc/gateway-validated-call.js";
15
+
16
+ /**
17
+ * Ask the gateway whether `channel`'s inbound socket is live.
18
+ *
19
+ * Throws on transport failure rather than reporting a channel as down: an
20
+ * unreachable gateway is a fact about the gateway, and a caller that renders
21
+ * it as "Slack is disconnected" would be inventing an outage. Callers are
22
+ * expected to treat a throw as indeterminate.
23
+ */
24
+ export async function readChannelSocketHealth(
25
+ channel: ChannelSocketHealthIpcParams["channel"],
26
+ ): Promise<ChannelSocketHealthIpcResponse> {
27
+ return ipcCallPersistentValidated(
28
+ "channel_socket_health",
29
+ { channel },
30
+ ChannelSocketHealthIpcResponseSchema,
31
+ );
32
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Daemon-side reader for how much Discord admits.
3
+ *
4
+ * The gateway owns the allow-list and the drop decision it feeds; the
5
+ * readiness surface that reports channel setup to the user lives here. This is
6
+ * the relay between them, and it exists for the same reason the socket-health
7
+ * relay does: the fact lives on one side of the boundary and is reported from
8
+ * the other.
9
+ */
10
+
11
+ import {
12
+ type DiscordAdmissionIpcResponse,
13
+ DiscordAdmissionIpcResponseSchema,
14
+ } from "@vellumai/gateway-client/gateway-ipc-contracts";
15
+
16
+ import { ipcCallPersistentValidated } from "../ipc/gateway-validated-call.js";
17
+
18
+ /**
19
+ * Ask the gateway how many channels Discord's allow-list admits.
20
+ *
21
+ * Throws on transport failure rather than reporting zero: an unreachable
22
+ * gateway is a fact about the gateway, and answering zero would tell an
23
+ * operator their allow-list is empty when nobody looked. Callers treat a throw
24
+ * as indeterminate.
25
+ */
26
+ export async function readDiscordAdmission(): Promise<DiscordAdmissionIpcResponse> {
27
+ return ipcCallPersistentValidated(
28
+ "discord_admission",
29
+ {},
30
+ DiscordAdmissionIpcResponseSchema,
31
+ );
32
+ }
@@ -111,6 +111,26 @@ export const CHANNEL_METADATA: Partial<Record<ChannelId, ChannelInfo>> = {
111
111
  "I'd like to verify a contact's Telegram identity. Can you walk me through it?",
112
112
  },
113
113
  },
114
+ discord: {
115
+ id: "discord",
116
+ label: "Discord",
117
+ subtitle: "Message your assistant from Discord",
118
+ // A Lucide name, per this field's contract, and the same one Slack
119
+ // carries: both name their text channels with a hash. A client with room
120
+ // for a brand mark uses one, which is how Slack's header already works.
121
+ icon: "hash",
122
+ // Verification needs nothing channel-specific here. The inbound intercept
123
+ // that matches a code takes the source channel as data, Discord inbound
124
+ // reaches it through the same `handleInbound` every channel uses, and a
125
+ // Discord user id canonicalizes as opaque, which is what it is.
126
+ supportsVerification: true,
127
+ setupMessages: {
128
+ guardian:
129
+ "I'd like to verify my identity as your guardian on Discord. Can you help me set that up?",
130
+ contact:
131
+ "I'd like to verify a contact's Discord identity. Can you walk me through it?",
132
+ },
133
+ },
114
134
  phone: {
115
135
  id: "phone",
116
136
  label: "Phone Calling",
@@ -112,7 +112,7 @@ mock.module("../../../messaging/providers/slack/send.js", () => ({
112
112
  },
113
113
  sendSlackStreamOp: async () => ({ ok: true, ts: "1700000000.000200" }),
114
114
  sendSlackReaction: async () => {},
115
- sendSlackAssistantThreadStatus: async () => {},
115
+ sendSlackAgentSessionStatus: async () => {},
116
116
  sendSlackAttachments: async () => ({
117
117
  allFailed: false,
118
118
  failureCount: 0,
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
11
11
  import { Command } from "commander";
12
12
 
13
13
  let lastIpcCall: { method: string; params?: any } | null = null;
14
+ let ipcCalls: { method: string; params?: any }[] = [];
14
15
  let mockIpcResult: { ok: boolean; result?: unknown; error?: string } = {
15
16
  ok: true,
16
17
  result: {},
@@ -19,6 +20,7 @@ let mockIpcResult: { ok: boolean; result?: unknown; error?: string } = {
19
20
  mock.module("../../../ipc/cli-client.js", () => ({
20
21
  cliIpcCall: async (method: string, params?: Record<string, unknown>) => {
21
22
  lastIpcCall = { method, params };
23
+ ipcCalls.push({ method, params });
22
24
  return mockIpcResult;
23
25
  },
24
26
  }));
@@ -36,6 +38,7 @@ const { inferenceHelp } = await import("../inference.help.js");
36
38
 
37
39
  beforeEach(() => {
38
40
  lastIpcCall = null;
41
+ ipcCalls = [];
39
42
  mockIpcResult = { ok: true, result: {} };
40
43
  process.exitCode = 0;
41
44
  });
@@ -136,8 +139,13 @@ describe("profiles create", () => {
136
139
  "--allow-unlisted",
137
140
  "--allow-unavailable",
138
141
  ]);
139
- expect(lastIpcCall?.method).toBe("inference_profiles_create");
140
- expect(lastIpcCall?.params?.body).toEqual({
142
+ const createCall = ipcCalls.find(
143
+ (c) => c.method === "inference_profiles_create",
144
+ );
145
+ expect(createCall).toBeDefined();
146
+ // The save is followed by the advisory probe call.
147
+ expect(ipcCalls.at(-1)?.method).toBe("inference_profiles_validate");
148
+ expect(createCall?.params?.body).toEqual({
141
149
  name: "p",
142
150
  provider: "anthropic",
143
151
  model: "claude-opus-4-8",
@@ -243,11 +251,15 @@ describe("profiles update", () => {
243
251
  result: { ok: true, name: "p", entry: {}, warnings: [] },
244
252
  };
245
253
  await run(["profiles", "update", "p", "--effort", "low"]);
246
- expect(lastIpcCall?.method).toBe("inference_profiles_update");
247
- expect(lastIpcCall?.params).toEqual({
254
+ const updateCall = ipcCalls.find(
255
+ (c) => c.method === "inference_profiles_update",
256
+ );
257
+ expect(updateCall?.params).toEqual({
248
258
  pathParams: { name: "p" },
249
259
  body: { effort: "low" },
250
260
  });
261
+ // The save is followed by the advisory probe call.
262
+ expect(ipcCalls.at(-1)?.method).toBe("inference_profiles_validate");
251
263
  });
252
264
 
253
265
  test("refuses an empty update", async () => {
@@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
11
11
  import { Command } from "commander";
12
12
 
13
13
  let ipcCalls: { method: string; params?: any }[] = [];
14
+ let openedUrls: string[] = [];
15
+ let storedSecrets: Array<{ key: string; value: string }> = [];
14
16
  let mockIpcResult: { ok: boolean; result?: unknown; error?: string } = {
15
17
  ok: true,
16
18
  result: {},
@@ -30,6 +32,35 @@ mock.module("../../../util/logger.js", () => ({
30
32
  new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
31
33
  }));
32
34
 
35
+ mock.module("../../lib/open-browser.js", () => ({
36
+ openInHostBrowser: (url: string) => {
37
+ openedUrls.push(url);
38
+ },
39
+ }));
40
+
41
+ mock.module("../../../security/oauth2.js", () => ({
42
+ startOAuth2Flow: async (
43
+ _config: unknown,
44
+ callbacks: { openUrl: (url: string) => void },
45
+ ) => {
46
+ callbacks.openUrl("https://auth.openai.com/oauth/authorize?state=test");
47
+ return {
48
+ tokens: {
49
+ accessToken: "test-access-token",
50
+ refreshToken: "test-refresh-token",
51
+ expiresIn: 3600,
52
+ },
53
+ };
54
+ },
55
+ }));
56
+
57
+ mock.module("../../../security/secure-keys.js", () => ({
58
+ setSecureKeyAsync: async (key: string, value: string) => {
59
+ storedSecrets.push({ key, value });
60
+ return true;
61
+ },
62
+ }));
63
+
33
64
  const { attachProvidersSubcommand } = await import("../inference-providers.js");
34
65
  const { applyCommandHelp } = await import("../../lib/cli-command-help.js");
35
66
  const { inferenceHelp } = await import("../inference.help.js");
@@ -48,6 +79,8 @@ function lastIpcCall(): { method: string; params?: any } | null {
48
79
 
49
80
  beforeEach(() => {
50
81
  ipcCalls = [];
82
+ openedUrls = [];
83
+ storedSecrets = [];
51
84
  mockIpcResult = { ok: true, result: CONNECTION_RESULT };
52
85
  process.exitCode = 0;
53
86
  });
@@ -58,13 +91,19 @@ afterEach(() => {
58
91
 
59
92
  async function run(
60
93
  args: string[],
61
- ): Promise<{ stdout: string; exitCode: number }> {
94
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
62
95
  const originalWrite = process.stdout.write.bind(process.stdout);
96
+ const originalErrorWrite = process.stderr.write.bind(process.stderr);
63
97
  const chunks: string[] = [];
98
+ const errorChunks: string[] = [];
64
99
  process.stdout.write = ((chunk: unknown) => {
65
100
  chunks.push(typeof chunk === "string" ? chunk : String(chunk));
66
101
  return true;
67
102
  }) as typeof process.stdout.write;
103
+ process.stderr.write = ((chunk: unknown) => {
104
+ errorChunks.push(typeof chunk === "string" ? chunk : String(chunk));
105
+ return true;
106
+ }) as typeof process.stderr.write;
68
107
 
69
108
  const prevExit = process.exitCode;
70
109
  process.exitCode = 0;
@@ -79,11 +118,13 @@ async function run(
79
118
  // swallow commander exits
80
119
  } finally {
81
120
  process.stdout.write = originalWrite;
121
+ process.stderr.write = originalErrorWrite;
82
122
  }
83
123
  const stdout = chunks.join("");
124
+ const stderr = errorChunks.join("");
84
125
  const exitCode = (process.exitCode as number) ?? 0;
85
126
  process.exitCode = prevExit;
86
- return { stdout, exitCode };
127
+ return { stdout, stderr, exitCode };
87
128
  }
88
129
 
89
130
  describe("providers create — derived auth", () => {
@@ -484,3 +525,27 @@ describe("deprecated providers connections alias", () => {
484
525
  expect(stdout).toContain("No providers found.");
485
526
  });
486
527
  });
528
+
529
+ describe("providers login-chatgpt", () => {
530
+ test("delivers the authorization URL through the browser helper and stderr", async () => {
531
+ const { stdout, stderr, exitCode } = await run([
532
+ "providers",
533
+ "login-chatgpt",
534
+ "--json",
535
+ ]);
536
+
537
+ expect(exitCode).toBe(0);
538
+ expect(JSON.parse(stdout)).toMatchObject({ ok: true });
539
+ expect(stderr).toContain(
540
+ "https://auth.openai.com/oauth/authorize?state=test",
541
+ );
542
+ expect(openedUrls).toEqual([
543
+ "https://auth.openai.com/oauth/authorize?state=test",
544
+ ]);
545
+ expect(storedSecrets).toContainEqual({
546
+ key: "credential/chatgpt/access_token",
547
+ value: "test-access-token",
548
+ });
549
+ expect(lastIpcCall()?.method).toBe("inference_provider_connections_update");
550
+ });
551
+ });
@@ -91,6 +91,91 @@ describe("assistant channels", () => {
91
91
  });
92
92
  });
93
93
 
94
+ describe("two-axis rendering", () => {
95
+ /**
96
+ * A configured channel whose delivery stopped is the state this command
97
+ * used to have no word for. Setup is finished, so telling the reader to
98
+ * finish setup sends them to re-enter credentials that are already
99
+ * correct, and the operational reason for the outage is the only thing
100
+ * they can act on.
101
+ */
102
+ const configuredButDown = (health: string) =>
103
+ emptySnapshot("slack", {
104
+ ready: false,
105
+ setupStatus: "ready",
106
+ health,
107
+ localChecks: [
108
+ {
109
+ name: "inbound_delivery",
110
+ passed: health !== "failing",
111
+ kind: "operational",
112
+ message: "Slack Socket Mode holds no live connection",
113
+ },
114
+ ],
115
+ });
116
+
117
+ test("list calls a configured channel that stopped delivering down, not unfinished", async () => {
118
+ mockResponses = [
119
+ {
120
+ ok: true,
121
+ result: { success: true, snapshots: [configuredButDown("failing")] },
122
+ },
123
+ ];
124
+ const out = await runCli("channels", "list");
125
+ expect(out).toContain("not delivering");
126
+ expect(out).not.toContain("incomplete");
127
+ expect(out).not.toContain("not configured");
128
+ });
129
+
130
+ test("list distinguishes a verdict it could not obtain from an outage", async () => {
131
+ mockResponses = [
132
+ {
133
+ ok: true,
134
+ result: { success: true, snapshots: [configuredButDown("unknown")] },
135
+ },
136
+ ];
137
+ const out = await runCli("channels", "list");
138
+ expect(out).toContain("state unknown");
139
+ expect(out).not.toContain("not delivering");
140
+ expect(out).not.toContain("incomplete");
141
+ });
142
+
143
+ test("setup that genuinely did not finish still reads incomplete", async () => {
144
+ // The sensitivity check on the two above: the word has to survive for
145
+ // the state it was always correct for.
146
+ mockResponses = [
147
+ {
148
+ ok: true,
149
+ result: {
150
+ success: true,
151
+ snapshots: [
152
+ emptySnapshot("slack", {
153
+ ready: false,
154
+ setupStatus: "incomplete",
155
+ }),
156
+ ],
157
+ },
158
+ },
159
+ ];
160
+ const out = await runCli("channels", "list");
161
+ expect(out).toContain("incomplete");
162
+ });
163
+
164
+ test("the detail header agrees with its own glyph", async () => {
165
+ // It used to print the raw setupStatus beside a glyph derived from both
166
+ // axes, so an outage rendered as a warning sign next to the word ready.
167
+ mockResponses = [
168
+ {
169
+ ok: true,
170
+ result: { success: true, snapshots: [configuredButDown("failing")] },
171
+ },
172
+ ];
173
+ const out = await runCli("channels", "get", "slack");
174
+ expect(out).toContain("slack — not delivering");
175
+ expect(out).not.toContain("slack — ready");
176
+ });
177
+ });
178
+
94
179
  describe("get", () => {
95
180
  test("always re-probes via the refresh route (live, never cached)", async () => {
96
181
  mockResponses = [