@vellumai/assistant 0.10.9-staging.1 → 0.10.9

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 (100) hide show
  1. package/AGENTS.md +4 -0
  2. package/knip.json +1 -4
  3. package/openapi.yaml +27 -17
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
  6. package/src/__tests__/app-routes-csp.test.ts +13 -4
  7. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
  8. package/src/__tests__/disk-pressure-tools.test.ts +4 -4
  9. package/src/__tests__/file-list-tool.test.ts +22 -12
  10. package/src/__tests__/list-all-apps.test.ts +204 -0
  11. package/src/__tests__/messaging-send-tool.test.ts +67 -0
  12. package/src/__tests__/mtime-cache.test.ts +42 -0
  13. package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
  14. package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
  15. package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
  16. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  17. package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
  18. package/src/__tests__/subagent-notify-parent.test.ts +4 -3
  19. package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
  20. package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
  21. package/src/apps/app-store.ts +302 -2
  22. package/src/cli/AGENTS.md +17 -0
  23. package/src/cli/commands/__tests__/gateway.test.ts +55 -0
  24. package/src/cli/commands/apps.help.ts +17 -11
  25. package/src/cli/commands/apps.ts +5 -15
  26. package/src/cli/commands/gateway.help.ts +30 -0
  27. package/src/cli/commands/gateway.ts +40 -1
  28. package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
  29. package/src/cli/commands/platform/index.help.ts +3 -2
  30. package/src/cli/commands/platform/index.ts +0 -9
  31. package/src/cli/commands/routes.help.ts +13 -7
  32. package/src/cli/commands/routes.ts +5 -3
  33. package/src/cli/commands/status.help.ts +24 -0
  34. package/src/cli/commands/status.ts +29 -0
  35. package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
  36. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
  37. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
  38. package/src/cli/lib/bundled-marketplace.json +187 -173
  39. package/src/cli/lib/global-json-option.ts +41 -0
  40. package/src/cli/lib/plugin-fingerprint.ts +18 -7
  41. package/src/cli/program.ts +5 -0
  42. package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
  43. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
  44. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  45. package/src/config/feature-flag-registry.json +1 -9
  46. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
  47. package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
  48. package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
  49. package/src/daemon/conversation-tool-setup.ts +5 -90
  50. package/src/daemon/conversation.ts +2 -2
  51. package/src/messaging/provider-types.ts +9 -0
  52. package/src/messaging/providers/outlook/adapter.ts +17 -2
  53. package/src/messaging/providers/outlook/client.ts +11 -2
  54. package/src/messaging/providers/outlook/types.ts +9 -0
  55. package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
  56. package/src/monitoring/plugin-source-watch.ts +53 -0
  57. package/src/oauth/seed-providers.ts +6 -0
  58. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
  59. package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
  60. package/src/plugin-api/conversation-turn.ts +234 -0
  61. package/src/plugin-api/index.ts +12 -0
  62. package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
  64. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
  65. package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
  66. package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
  67. package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
  68. package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
  69. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
  70. package/src/plugins/mtime-cache.ts +26 -13
  71. package/src/plugins/plugin-tree-walk.ts +30 -0
  72. package/src/plugins/source-fingerprint.ts +9 -2
  73. package/src/plugins/surface-import.ts +28 -0
  74. package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
  75. package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
  76. package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
  77. package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
  78. package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
  79. package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
  80. package/src/runtime/routes/app-management-routes.ts +96 -32
  81. package/src/runtime/routes/app-routes.ts +33 -17
  82. package/src/runtime/routes/gateway-status-routes.ts +69 -0
  83. package/src/runtime/routes/index.ts +2 -0
  84. package/src/runtime/routes/platform-routes.ts +4 -14
  85. package/src/runtime/routes/plugins-routes.ts +58 -1
  86. package/src/runtime/routes/settings-routes.ts +8 -6
  87. package/src/runtime/routes/user-route-dispatcher.ts +6 -86
  88. package/src/runtime/routes/user-route-resolution.ts +141 -0
  89. package/src/runtime/routes/user-routes-cli.ts +56 -35
  90. package/src/telemetry/AGENTS.md +15 -0
  91. package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
  92. package/src/telemetry/telemetry-wire-source.json +1 -1
  93. package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
  94. package/src/telemetry/telemetry-wire-validation.ts +125 -0
  95. package/src/telemetry/telemetry-wire.generated.ts +60 -9
  96. package/src/telemetry/types.test.ts +38 -0
  97. package/src/telemetry/types.ts +183 -91
  98. package/src/telemetry/usage-telemetry-reporter.ts +5 -0
  99. package/src/telemetry/watchdog-direct-emit.ts +6 -0
  100. package/src/tools/skills/scaffold-managed.ts +43 -11
@@ -35,10 +35,11 @@ import {
35
35
  } from "node:path";
36
36
 
37
37
  import { rawAll } from "../persistence/raw-query.js";
38
+ import { isPluginDisabled } from "../plugins/disabled-state.js";
38
39
  import type { EditEngineResult } from "../tools/shared/filesystem/edit-engine.js";
39
40
  import { applyEdit } from "../tools/shared/filesystem/edit-engine.js";
40
41
  import { getLogger } from "../util/logger.js";
41
- import { getDataDir } from "../util/platform.js";
42
+ import { getDataDir, getWorkspacePluginsDir } from "../util/platform.js";
42
43
 
43
44
  export interface AppDefinition {
44
45
  id: string;
@@ -85,6 +86,28 @@ export function resolveEffectiveAppHtml(app: AppDefinition): string {
85
86
  return `<p>App compilation failed. Edit a source file to trigger a rebuild.</p>`;
86
87
  }
87
88
 
89
+ /**
90
+ * Resolve the effective, self-contained HTML for an app given its source
91
+ * directory and format, without needing a loaded {@link AppDefinition}. Used
92
+ * to render apps addressed by path (e.g. plugin-bundled apps). Single-file
93
+ * apps serve their root index.html; multi-file apps serve the compiled
94
+ * dist/index.html with JS/CSS inlined.
95
+ */
96
+ export function resolveEffectiveAppHtmlFromDir(
97
+ sourceDir: string,
98
+ formatVersion: number,
99
+ ): string {
100
+ if (formatVersion !== 2) {
101
+ const indexPath = join(sourceDir, "index.html");
102
+ return existsSync(indexPath) ? readFileSync(indexPath, "utf-8") : "";
103
+ }
104
+ const distIndex = join(sourceDir, "dist", "index.html");
105
+ if (existsSync(distIndex)) {
106
+ return inlineDistAssets(sourceDir, readFileSync(distIndex, "utf-8"));
107
+ }
108
+ return `<p>App compilation failed. Edit a source file to trigger a rebuild.</p>`;
109
+ }
110
+
88
111
  /**
89
112
  * Inline dist assets (main.js, main.css) into the compiled HTML so it can be
90
113
  * delivered as a self-contained string via loadHTMLString/SSE without needing
@@ -377,6 +400,16 @@ function invalidateDirNameCache(appId?: string): void {
377
400
  * Returns the resolved absolute path.
378
401
  */
379
402
  function validateFilePath(appId: string, path: string): string {
403
+ return validateFilePathInDir(getAppDirPath(appId), path);
404
+ }
405
+
406
+ /**
407
+ * Validate a relative file path within a known app directory (absolute path).
408
+ * Prevents path traversal and access to protected directories. Used when the
409
+ * app directory is already resolved (e.g. plugin-bundled apps addressed by
410
+ * path rather than by a workspace app id).
411
+ */
412
+ function validateFilePathInDir(appDir: string, path: string): string {
380
413
  if (!path || path.trim() === "") {
381
414
  throw new Error(`Invalid file path: path is empty`);
382
415
  }
@@ -391,7 +424,6 @@ function validateFilePath(appId: string, path: string): string {
391
424
  if (normalized === "records" || normalized.startsWith("records/")) {
392
425
  throw new Error(`Invalid file path: 'records/' directory is protected`);
393
426
  }
394
- const appDir = getAppDirPath(appId);
395
427
  const resolved = resolve(appDir, path);
396
428
  // Ensure the resolved path is still within the app directory
397
429
  if (!resolved.startsWith(appDir + "/") && resolved !== appDir) {
@@ -624,6 +656,257 @@ export function listApps(): AppDefinition[] {
624
656
  return apps;
625
657
  }
626
658
 
659
+ // ---------------------------------------------------------------------------
660
+ // Cross-source enumeration (workspace + plugin-bundled apps)
661
+ // ---------------------------------------------------------------------------
662
+
663
+ /**
664
+ * Where an app is provided from. Workspace apps are user-created and live under
665
+ * `<workspace>/data/apps/`; plugin apps are bundled by an installed plugin
666
+ * under `<workspace>/plugins/<name>/apps/`.
667
+ */
668
+ export type AppOrigin =
669
+ | { readonly kind: "workspace" }
670
+ | { readonly kind: "plugin"; readonly pluginName: string };
671
+
672
+ /**
673
+ * Id prefix marking a plugin-bundled app. A plugin app's id encodes its
674
+ * location: `plugins~<pluginName>~<appDir>`, which maps to
675
+ * `<workspace>/plugins/<pluginName>/apps/<appDir>` (the `apps/` segment is
676
+ * implied). Unlike workspace apps — whose id is an opaque UUID looked up by
677
+ * scanning — a plugin app is resolved by a direct path build.
678
+ *
679
+ * The delimiter is `~` (a URL-unreserved character), not `/`, so the id is a
680
+ * single URL path segment: it survives `:id` route params and proxies without
681
+ * percent-encoding (`%2F` in a path is widely mishandled).
682
+ */
683
+ const PLUGIN_APP_ID_PREFIX = "plugins~";
684
+ const PLUGIN_APP_ID_SEP = "~";
685
+
686
+ /** Build the addressable id for a plugin-bundled app. */
687
+ function pluginAppId(pluginName: string, appDir: string): string {
688
+ return `${PLUGIN_APP_ID_PREFIX}${pluginName}${PLUGIN_APP_ID_SEP}${appDir}`;
689
+ }
690
+
691
+ /** True when an app id addresses a plugin-bundled app (vs a workspace app). */
692
+ export function isPluginAppId(id: string): boolean {
693
+ return id.startsWith(PLUGIN_APP_ID_PREFIX);
694
+ }
695
+
696
+ /** An app paired with the absolute path to its source and its origin. */
697
+ export interface EnumeratedApp {
698
+ /**
699
+ * Addressable app id: an opaque UUID for workspace apps, or
700
+ * `plugins~<name>~<app>` for plugin-bundled apps.
701
+ */
702
+ readonly id: string;
703
+ /** Human-readable app name. */
704
+ readonly name: string;
705
+ /** Absolute path to the app's source directory. */
706
+ readonly sourcePath: string;
707
+ /** Where the app is provided from. */
708
+ readonly origin: AppOrigin;
709
+ }
710
+
711
+ /**
712
+ * Enumerate apps bundled under a single plugin's `apps/` directory. Each
713
+ * immediate subdirectory is one app: its directory name is the app name and
714
+ * its path is the source.
715
+ */
716
+ function listAppsForPlugin(
717
+ pluginName: string,
718
+ pluginDir: string,
719
+ ): EnumeratedApp[] {
720
+ const appsDir = join(pluginDir, "apps");
721
+ let entries: string[];
722
+ try {
723
+ entries = readdirSync(appsDir);
724
+ } catch {
725
+ // No apps/ directory (or unreadable) — this plugin bundles no apps.
726
+ return [];
727
+ }
728
+ const apps: EnumeratedApp[] = [];
729
+ for (const entry of entries) {
730
+ const appDir = join(appsDir, entry);
731
+ try {
732
+ // statSync follows symlinks so a symlinked app directory still counts.
733
+ if (!statSync(appDir).isDirectory()) {
734
+ continue;
735
+ }
736
+ } catch {
737
+ continue;
738
+ }
739
+ apps.push({
740
+ id: pluginAppId(pluginName, entry),
741
+ name: entry,
742
+ sourcePath: appDir,
743
+ origin: { kind: "plugin", pluginName },
744
+ });
745
+ }
746
+ return apps;
747
+ }
748
+
749
+ /**
750
+ * Enumerate apps bundled by installed, enabled plugins under
751
+ * `<workspace>/plugins/<name>/apps/`.
752
+ *
753
+ * Plugin discovery mirrors the plugin loader's `scanPlugins`: a plugin is an
754
+ * entry that resolves to a directory (following symlinks) and carries a
755
+ * `package.json` manifest. Stray directories without a manifest are ignored,
756
+ * and disabled plugins (those with a `.disabled` sentinel) contribute nothing,
757
+ * matching how their other surfaces (tools, hooks, routes) are gated.
758
+ */
759
+ export function listPluginApps(): EnumeratedApp[] {
760
+ const pluginsRoot = getWorkspacePluginsDir();
761
+ if (!existsSync(pluginsRoot)) {
762
+ return [];
763
+ }
764
+ let entries: string[];
765
+ try {
766
+ entries = readdirSync(pluginsRoot);
767
+ } catch {
768
+ return [];
769
+ }
770
+ const apps: EnumeratedApp[] = [];
771
+ for (const name of entries) {
772
+ const pluginDir = join(pluginsRoot, name);
773
+ try {
774
+ if (!statSync(pluginDir).isDirectory()) {
775
+ continue;
776
+ }
777
+ } catch {
778
+ continue;
779
+ }
780
+ if (!existsSync(join(pluginDir, "package.json"))) {
781
+ continue;
782
+ }
783
+ if (isPluginDisabled(name)) {
784
+ continue;
785
+ }
786
+ apps.push(...listAppsForPlugin(name, pluginDir));
787
+ }
788
+ return apps;
789
+ }
790
+
791
+ /**
792
+ * Enumerate every app the assistant can surface, tagged with its origin:
793
+ * workspace apps (user-created) followed by apps bundled by installed plugins.
794
+ */
795
+ export function listAllApps(): EnumeratedApp[] {
796
+ const workspaceApps: EnumeratedApp[] = listApps().map((definition) => ({
797
+ id: definition.id,
798
+ name: definition.name,
799
+ sourcePath: getAppDirPath(definition.id),
800
+ origin: { kind: "workspace" as const },
801
+ }));
802
+ return [...workspaceApps, ...listPluginApps()];
803
+ }
804
+
805
+ /** An app id resolved to its on-disk source and metadata. */
806
+ export interface ResolvedAppSource {
807
+ /** The id that was resolved (echoed back for the caller). */
808
+ readonly id: string;
809
+ /** Human-readable app name. */
810
+ readonly name: string;
811
+ /** Filesystem directory stem — the workspace slug or the plugin app dir. */
812
+ readonly dirName: string;
813
+ /** Absolute path to the app's source directory. */
814
+ readonly sourceDir: string;
815
+ /** Where the app is provided from. */
816
+ readonly origin: AppOrigin;
817
+ /** 1 = single-file (index.html at root); 2 = multi-file (compiled dist/). */
818
+ readonly formatVersion: number;
819
+ }
820
+
821
+ /**
822
+ * Validate a single plugin-app id path segment (plugin name or app directory).
823
+ * Rejects empty, traversal, separators, and untrimmed values so an id can be
824
+ * mapped to a filesystem path without escaping the plugins root.
825
+ */
826
+ function isSafeIdSegment(segment: string): boolean {
827
+ return (
828
+ segment.length > 0 &&
829
+ segment === segment.trim() &&
830
+ !segment.includes("/") &&
831
+ !segment.includes("\\") &&
832
+ !segment.includes("..") &&
833
+ segment !== "."
834
+ );
835
+ }
836
+
837
+ /**
838
+ * Resolve an app id to its on-disk source, for both workspace apps (opaque
839
+ * UUID, looked up via {@link getApp}) and plugin-bundled apps
840
+ * (`plugins~<name>~<app>`, resolved by direct path build). Returns null when
841
+ * the app does not exist, or when a plugin id fails the same installed-plugin
842
+ * gates as discovery (directory, `package.json` manifest, not disabled).
843
+ */
844
+ export function resolveAppSource(id: string): ResolvedAppSource | null {
845
+ if (id.startsWith(PLUGIN_APP_ID_PREFIX)) {
846
+ const rest = id.slice(PLUGIN_APP_ID_PREFIX.length);
847
+ // Split on the first delimiter: the plugin name is kebab-case (no `~`), so
848
+ // everything after it is the app directory (which may itself contain `~`).
849
+ const sep = rest.indexOf(PLUGIN_APP_ID_SEP);
850
+ if (sep === -1) {
851
+ return null;
852
+ }
853
+ const pluginName = rest.slice(0, sep);
854
+ const appDirName = rest.slice(sep + PLUGIN_APP_ID_SEP.length);
855
+ // Both must be safe single path components — the app segment, used as a
856
+ // directory name, must not smuggle in separators or traversal.
857
+ if (!isSafeIdSegment(pluginName) || !isSafeIdSegment(appDirName)) {
858
+ return null;
859
+ }
860
+ const pluginDir = join(getWorkspacePluginsDir(), pluginName);
861
+ try {
862
+ if (!statSync(pluginDir).isDirectory()) {
863
+ return null;
864
+ }
865
+ } catch {
866
+ return null;
867
+ }
868
+ if (!existsSync(join(pluginDir, "package.json"))) {
869
+ return null;
870
+ }
871
+ if (isPluginDisabled(pluginName)) {
872
+ return null;
873
+ }
874
+ const sourceDir = join(pluginDir, "apps", appDirName);
875
+ try {
876
+ if (!statSync(sourceDir).isDirectory()) {
877
+ return null;
878
+ }
879
+ } catch {
880
+ return null;
881
+ }
882
+ // Single-file apps ship index.html at the root; anything else is treated
883
+ // as multi-file (served from a compiled dist/).
884
+ const formatVersion = existsSync(join(sourceDir, "index.html")) ? 1 : 2;
885
+ return {
886
+ id,
887
+ name: appDirName,
888
+ dirName: appDirName,
889
+ sourceDir,
890
+ origin: { kind: "plugin", pluginName },
891
+ formatVersion,
892
+ };
893
+ }
894
+
895
+ const app = getApp(id);
896
+ if (!app) {
897
+ return null;
898
+ }
899
+ const { dirName } = resolveAppDir(id);
900
+ return {
901
+ id,
902
+ name: app.name,
903
+ dirName,
904
+ sourceDir: getAppDirPath(id),
905
+ origin: { kind: "workspace" },
906
+ formatVersion: app.formatVersion ?? 1,
907
+ };
908
+ }
909
+
627
910
  export function updateApp(
628
911
  id: string,
629
912
  updates: Partial<
@@ -880,6 +1163,23 @@ export function readAppFileBytes(appId: string, path: string): Buffer {
880
1163
  return readFileSync(resolved);
881
1164
  }
882
1165
 
1166
+ /**
1167
+ * Read a file as raw bytes from an app directory addressed by its absolute
1168
+ * path (rather than by a workspace app id). Path is validated to prevent
1169
+ * traversal. Used to serve assets for plugin-bundled apps, whose source dir is
1170
+ * resolved via {@link resolveAppSource}.
1171
+ */
1172
+ export function readAppFileBytesFromDir(
1173
+ sourceDir: string,
1174
+ path: string,
1175
+ ): Buffer {
1176
+ const resolved = validateFilePathInDir(sourceDir, path);
1177
+ if (!existsSync(resolved)) {
1178
+ throw new Error(`File not found: ${path}`);
1179
+ }
1180
+ return readFileSync(resolved);
1181
+ }
1182
+
883
1183
  /**
884
1184
  * Write a file to the app directory.
885
1185
  * Auto-creates intermediate directories. Path is validated to prevent traversal.
package/src/cli/AGENTS.md CHANGED
@@ -154,6 +154,23 @@ registerFooCommand(program);
154
154
  `console.log` results directly; that bypasses the convention and
155
155
  breaks downstream scripting.
156
156
 
157
+ **`--json` is global.** `registerGlobalJsonOption` (called once in
158
+ `program.ts` after every command is registered) attaches `--json` to
159
+ every **leaf** command in the tree, so a caller can append `--json` to
160
+ any command it actually invokes without hitting "unknown option". Only
161
+ leaves get it: Commander consumes a recognized option at the outermost
162
+ command that declares it, even when the flag appears after the
163
+ subcommand name, so a group-level `--json` would swallow
164
+ `clients list --json` before the `list` action reads `opts.json`. You
165
+ therefore do **not** need to declare `--json` yourself — but a command
166
+ only _honors_ it when its action routes output through
167
+ `writeOutput`/`shouldOutputJson` (or reads `opts.json`). When a command
168
+ emits bespoke human-readable text, branch on `shouldOutputJson(cmd)` and
169
+ emit a JSON object in the `--json` case (see `commands/status.ts`).
170
+ Declare `--json` explicitly in the command's `.help` module only when
171
+ you want a more specific description than the generic one; the global
172
+ registration skips any leaf that already declares it.
173
+
157
174
  ### Anti-patterns
158
175
 
159
176
  - ❌ Hoisting a daemon import — `import { x } from "../../runtime/foo.js"`
@@ -33,6 +33,7 @@ let mockResponses: Array<{
33
33
  ok: boolean;
34
34
  result?: unknown;
35
35
  error?: string;
36
+ statusCode?: number;
36
37
  }> = [];
37
38
 
38
39
  // ---------------------------------------------------------------------------
@@ -44,6 +45,10 @@ mock.module("../../../ipc/cli-client.js", () => ({
44
45
  ipcCalls.push({ method, params });
45
46
  return mockResponses.shift() ?? { ok: true, result: null };
46
47
  },
48
+ exitFromIpcResult: (r: { error?: string }) => {
49
+ process.exitCode = 1;
50
+ if (r.error) process.stderr.write(r.error);
51
+ },
47
52
  }));
48
53
 
49
54
  mock.module("../../../util/logger.js", () => ({
@@ -179,6 +184,56 @@ describe("gateway logs tail — IPC params", () => {
179
184
  });
180
185
  });
181
186
 
187
+ // ---------------------------------------------------------------------------
188
+ // gateway status
189
+ // ---------------------------------------------------------------------------
190
+
191
+ describe("gateway status", () => {
192
+ test("--json passes through the connected tunnel payload", async () => {
193
+ mockResponses.push({
194
+ ok: true,
195
+ result: { tunnel: "https://abc123.vellum.ai" },
196
+ });
197
+
198
+ const { stdout, exitCode } = await runCommand([
199
+ "gateway",
200
+ "status",
201
+ "--json",
202
+ ]);
203
+
204
+ expect(exitCode).toBe(0);
205
+ expect(ipcCalls[0].method).toBe("gateway_status");
206
+ expect(JSON.parse(stdout.trim())).toEqual({
207
+ tunnel: "https://abc123.vellum.ai",
208
+ });
209
+ });
210
+
211
+ test("--json emits {} when no tunnel is connected", async () => {
212
+ mockResponses.push({ ok: true, result: {} });
213
+
214
+ const { stdout, exitCode } = await runCommand([
215
+ "gateway",
216
+ "status",
217
+ "--json",
218
+ ]);
219
+
220
+ expect(exitCode).toBe(0);
221
+ expect(JSON.parse(stdout.trim())).toEqual({});
222
+ });
223
+
224
+ test("exits non-zero when the gateway is not running", async () => {
225
+ mockResponses.push({
226
+ ok: false,
227
+ error: "Gateway is not running or is unreachable over IPC.",
228
+ statusCode: 503,
229
+ });
230
+
231
+ const { exitCode } = await runCommand(["gateway", "status"]);
232
+
233
+ expect(exitCode).not.toBe(0);
234
+ });
235
+ });
236
+
182
237
  // ---------------------------------------------------------------------------
183
238
  // Pretty-print output
184
239
  // ---------------------------------------------------------------------------
@@ -4,15 +4,21 @@ import type { CliCommandHelp } from "../lib/cli-command-help.js";
4
4
 
5
5
  export const appsHelp: CliCommandHelp = {
6
6
  name: "apps",
7
- description: "Inspect user-defined apps and their on-disk source",
7
+ description: "Inspect apps and their on-disk source",
8
8
  helpText: `
9
- Apps are user-defined mini-applications stored under the workspace apps
10
- directory (~/.vellum/apps/<slug>/). Each app has a human-readable name and a
9
+ Apps are mini-applications the assistant can surface. Each has a name and a
11
10
  source directory holding its HTML/TSX definition, pages, and records.
12
11
 
13
- This surface is read-only. Apps are created, edited, and deleted through the
14
- app-builder skill and its tools (app-create, app-update, app-delete) — there is
15
- no CLI create/update/delete verb by design.
12
+ Apps come from two places, distinguished by their source path:
13
+ workspace User-created apps under the workspace apps directory
14
+ (~/.vellum/apps/<slug>/).
15
+ plugin Apps bundled by an installed plugin, each a directory under
16
+ ~/.vellum/plugins/<name>/apps/<app>/. Stray directories without
17
+ a plugin package.json, and disabled plugins, contribute nothing.
18
+
19
+ This surface is read-only. Workspace apps are created, edited, and deleted
20
+ through the app-builder skill and its tools (app-create, app-update,
21
+ app-delete); plugin apps are owned by their plugin.
16
22
 
17
23
  Examples:
18
24
  $ assistant apps list
@@ -25,12 +31,12 @@ Examples:
25
31
  { flags: "--json", description: "Machine-readable JSON output" },
26
32
  ],
27
33
  helpText: `
28
- Lists every app with its name and the absolute path to its source directory.
29
- The source path is the app's directory under the workspace apps directory; it
30
- holds index.html (single-file apps) or the TSX source tree (multi-file apps).
34
+ Lists every app with its name and the absolute path to its source directory
35
+ the path itself shows whether the app is a workspace app or bundled by a plugin
36
+ (~/.vellum/plugins/<name>/apps/...). Workspace apps are listed first, then
37
+ plugin-bundled apps; both are sorted by name.
31
38
 
32
- Pass --json for the full record, including the app ID, format version, and
33
- last-updated time.
39
+ Pass --json for machine-readable output.
34
40
 
35
41
  Examples:
36
42
  $ assistant apps list
@@ -8,9 +8,6 @@ import { appsHelp } from "./apps.help.js";
8
8
  interface AppListEntry {
9
9
  name: string;
10
10
  source: string;
11
- id: string;
12
- formatVersion: number;
13
- updatedAt: number;
14
11
  }
15
12
 
16
13
  export function registerAppsCommand(program: Command): void {
@@ -24,17 +21,10 @@ export function registerAppsCommand(program: Command): void {
24
21
  subcommand(apps, "list").action(async (opts: { json?: boolean }) => {
25
22
  // Lazy-import the app store so the daemon module graph loads only when
26
23
  // this command runs (cli/no-daemon-internals).
27
- const { getAppDirPath, listApps } =
28
- await import("../../apps/app-store.js");
29
-
30
- const entries: AppListEntry[] = listApps()
31
- .map((app) => ({
32
- name: app.name,
33
- source: getAppDirPath(app.id),
34
- id: app.id,
35
- formatVersion: app.formatVersion ?? 1,
36
- updatedAt: app.updatedAt,
37
- }))
24
+ const { listAllApps } = await import("../../apps/app-store.js");
25
+
26
+ const entries: AppListEntry[] = listAllApps()
27
+ .map(({ name, sourcePath }) => ({ name, source: sourcePath }))
38
28
  .sort((a, b) => a.name.localeCompare(b.name));
39
29
 
40
30
  if (opts.json) {
@@ -48,7 +38,7 @@ export function registerAppsCommand(program: Command): void {
48
38
  }
49
39
 
50
40
  // Name and source lead; align the name into a column so the source
51
- // path is scannable across rows.
41
+ // path (which also identifies the app's origin) stays scannable.
52
42
  const nameWidth = Math.max(4, ...entries.map((e) => e.name.length));
53
43
  log.info(`Apps (${entries.length}):\n`);
54
44
  log.info(` ${"NAME".padEnd(nameWidth)} SOURCE`);
@@ -11,11 +11,41 @@ manages trust rules, routes traffic to the assistant, and records
11
11
  structured logs for all inbound activity.
12
12
 
13
13
  Examples:
14
+ $ assistant gateway status
14
15
  $ assistant gateway logs tail
15
16
  $ assistant gateway logs tail -n 50
16
17
  $ assistant gateway logs tail --level warn
17
18
  $ assistant gateway logs tail --module cors`,
18
19
  subcommands: [
20
+ {
21
+ name: "status",
22
+ description: "Show gateway status (public tunnel state)",
23
+ options: [
24
+ {
25
+ flags: "--json",
26
+ description: "Machine-readable compact JSON output",
27
+ },
28
+ ],
29
+ helpText: `
30
+ Reports the live status of the gateway's public tunnel — the outbound
31
+ public-ingress transport (currently Velay).
32
+
33
+ The tunnel is ONLY used to route inbound Twilio webhooks and live voice/audio
34
+ WebSockets through to this assistant. It has nothing to do with platform
35
+ credentials, the managed LLM proxy, or text channels. If you are not using
36
+ Twilio voice or live audio, a "not connected" tunnel is expected and harmless.
37
+
38
+ JSON output (--json):
39
+ { "tunnel": "<public-url>" } when a tunnel is connected
40
+ { } when the gateway is running but no tunnel is up
41
+
42
+ If the gateway itself is not running, the command exits non-zero with an error
43
+ rather than reporting an empty tunnel.
44
+
45
+ Examples:
46
+ $ assistant gateway status
47
+ $ assistant gateway status --json`,
48
+ },
19
49
  {
20
50
  name: "logs",
21
51
  description: "Gateway log operations",
@@ -2,15 +2,17 @@
2
2
  * `assistant gateway` CLI namespace.
3
3
  *
4
4
  * Subcommands:
5
+ * status — Show the gateway's public tunnel status via the daemon IPC proxy.
5
6
  * logs tail — Show the last N gateway log entries via the daemon IPC proxy.
6
7
  */
7
8
 
8
9
  import type { Command } from "commander";
9
10
 
10
- import { cliIpcCall } from "../../ipc/cli-client.js";
11
+ import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js";
11
12
  import { applyCommandHelp, subcommand } from "../lib/cli-command-help.js";
12
13
  import { registerCommand } from "../lib/register-command.js";
13
14
  import { log } from "../logger.js";
15
+ import { shouldOutputJson, writeOutput } from "../output.js";
14
16
  import { gatewayHelp } from "./gateway.help.js";
15
17
 
16
18
  // -- Types --------------------------------------------------------------------
@@ -23,6 +25,10 @@ interface PinoEntry {
23
25
  [key: string]: unknown;
24
26
  }
25
27
 
28
+ interface GatewayStatusResult {
29
+ tunnel?: string;
30
+ }
31
+
26
32
  // -- Helpers ------------------------------------------------------------------
27
33
 
28
34
  function formatTime(ms: number): string {
@@ -66,6 +72,39 @@ export function registerGatewayCommand(program: Command): void {
66
72
  build: (gateway) => {
67
73
  applyCommandHelp(gateway, gatewayHelp);
68
74
 
75
+ // -----------------------------------------------------------------------
76
+ // status
77
+ // -----------------------------------------------------------------------
78
+
79
+ subcommand(gateway, "status").action(async (_opts, cmd: Command) => {
80
+ const r = await cliIpcCall<GatewayStatusResult>("gateway_status", {});
81
+ if (!r.ok)
82
+ return exitFromIpcResult(
83
+ { ok: false, error: r.error, statusCode: r.statusCode },
84
+ cmd,
85
+ );
86
+
87
+ const result = r.result!;
88
+
89
+ if (shouldOutputJson(cmd)) {
90
+ writeOutput(cmd, result);
91
+ return;
92
+ }
93
+
94
+ if (result.tunnel) {
95
+ log.info(`Tunnel: connected (${result.tunnel})`);
96
+ } else {
97
+ log.info("Tunnel: not connected");
98
+ }
99
+ log.info(
100
+ " The public tunnel is only used to route inbound Twilio webhooks and",
101
+ );
102
+ log.info(
103
+ " live voice/audio WebSockets. It is not needed for text channels or",
104
+ );
105
+ log.info(" the managed LLM proxy.");
106
+ });
107
+
69
108
  const logs = subcommand(gateway, "logs");
70
109
 
71
110
  subcommand(logs, "tail").action(async (opts) => {