@app-vitals/agent-admin 0.16.2 → 1.1.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 (47) hide show
  1. package/e2e/agents-page.e2e.ts +9 -9
  2. package/e2e/test-server.ts +6 -6
  3. package/openapi.json +3 -3
  4. package/openapi.platform.json +3 -3
  5. package/package.json +3 -5
  6. package/src/account-agent-create.ts +1 -1
  7. package/src/admin-boot.ts +6 -6
  8. package/src/admin-extension.ts +16 -15
  9. package/src/admin-extension.unit.test.ts +2 -2
  10. package/src/admin-local-agent.smoke.test.ts +8 -5
  11. package/src/admin-ui-pages.ts +4 -4
  12. package/src/admin-ui.smoke.test.ts +27 -39
  13. package/src/admin-ui.ts +12 -12
  14. package/src/agent-chat-tokens.smoke.test.ts +2 -2
  15. package/src/agent-cron-jobs.integration.test.ts +23 -21
  16. package/src/agent-cron-jobs.ts +30 -40
  17. package/src/agent-cron-jobs.unit.test.ts +51 -39
  18. package/src/agent-cron-run-stats.smoke.test.ts +2 -2
  19. package/src/agent-manifest.ts +2 -6
  20. package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
  21. package/src/agents-api.integration.test.ts +20 -31
  22. package/src/agents-api.sentry.smoke.test.ts +2 -2
  23. package/src/agents-api.smoke.test.ts +17 -30
  24. package/src/agents-api.ts +13 -13
  25. package/src/agents.integration.test.ts +21 -31
  26. package/src/agents.ts +27 -34
  27. package/src/agents.unit.test.ts +35 -55
  28. package/src/api.smoke.test.ts +2 -2
  29. package/src/api.ts +1 -1
  30. package/src/chat-service-provisioning-client.integration.test.ts +1 -1
  31. package/src/server.smoke.test.ts +2 -2
  32. package/src/specialty-defaults.ts +4 -4
  33. package/src/specialty-types.ts +92 -0
  34. package/src/specialty-types.unit.test.ts +67 -0
  35. package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
  36. package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
  37. package/src/coding-task-store-provisioning-client.ts +0 -156
  38. package/src/fixtures/task-store-provisioning-cassette.json +0 -85
  39. package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
  40. package/src/specialty-manifest-loader.ts +0 -285
  41. package/src/specialty-manifest-loader.unit.test.ts +0 -364
  42. package/src/specialty-registry.schema.unit.test.ts +0 -35
  43. package/src/specialty-registry.ts +0 -236
  44. package/src/specialty-registry.unit.test.ts +0 -272
  45. /package/e2e/fixtures/specialties/{coding → specialist}/templates/CLAUDE.md.template +0 -0
  46. /package/e2e/fixtures/specialties/{coding → specialist}/templates/IDENTITY.md.template +0 -0
  47. /package/e2e/fixtures/specialties/{coding → specialist}/templates/SOUL.md.template +0 -0
@@ -1,156 +0,0 @@
1
- /**
2
- * admin/src/coding-task-store-provisioning-client.ts
3
- *
4
- * Thin interface + implementations for minting/revoking per-agent task-store
5
- * tokens during agent provisioning.
6
- *
7
- * The interface is the DI boundary. Tests inject a stub; production wires in
8
- * HttpTaskStoreProvisioningClient (or skips task-store wiring entirely by
9
- * omitting the client from KubernetesAgentProvisionerConfig).
10
- */
11
-
12
- import type { components } from "@app-vitals/agent-lib/task-store-types";
13
-
14
- // ─── Interface ───────────────────────────────────────────────────────────────
15
-
16
- export interface TaskStoreProvisioningClient {
17
- /**
18
- * Mint a new task-store token. Returns the token `id` (needed to revoke it
19
- * on rollback) and the `rawToken` (stored in the agent Secret and later used
20
- * by the agent to authenticate with the task store).
21
- *
22
- * Pass `agentId` to create a scoped agent token — the task store will scope
23
- * all reads and writes to tasks assigned to this agent. Omit for admin tokens.
24
- */
25
- mintToken(
26
- label: string,
27
- agentId?: string,
28
- ): Promise<{ id: string; rawToken: string }>;
29
-
30
- /**
31
- * Revoke a previously-minted task-store token. Called as part of the rollback
32
- * path when Deployment creation fails after the token was minted, and as
33
- * part of agent-deletion credential cleanup.
34
- * Should be best-effort — callers do not propagate errors from this call.
35
- */
36
- revokeToken(id: string): Promise<void>;
37
-
38
- /**
39
- * List the ids of every task-store token scoped to `agentId`.
40
- *
41
- * task-store's GET /tokens has no server-side agentId filter — it returns
42
- * every token in the store — so this fetches the full list and filters
43
- * client-side. Intended to be paired with `revokeToken` by the caller (e.g.
44
- * agent-deletion cleanup): list, then revoke each match.
45
- */
46
- listTokensForAgent(agentId: string): Promise<{ id: string }[]>;
47
- }
48
-
49
- // ─── HTTP implementation ──────────────────────────────────────────────────────
50
-
51
- /**
52
- * Real HTTP client for the task-store token API.
53
- *
54
- * POST /tokens → { id, rawToken, ... }
55
- * DELETE /tokens/:id
56
- *
57
- * The admin token is passed via Bearer auth and is never stored in the Secret
58
- * or handed to the agent — only the per-agent rawToken is.
59
- */
60
- export class HttpTaskStoreProvisioningClient
61
- implements TaskStoreProvisioningClient
62
- {
63
- private readonly fetchFn: typeof fetch;
64
-
65
- constructor(
66
- private readonly baseUrl: string,
67
- private readonly adminToken: string,
68
- opts?: { fetchFn?: typeof fetch },
69
- ) {
70
- this.fetchFn = opts?.fetchFn ?? fetch;
71
- }
72
-
73
- async mintToken(
74
- label: string,
75
- agentId?: string,
76
- ): Promise<{ id: string; rawToken: string }> {
77
- const tokenBody: components["schemas"]["TokenBody"] = {
78
- label,
79
- ...(agentId ? { agentId } : {}),
80
- };
81
- const res = await this.fetchFn(`${this.baseUrl}/tokens`, {
82
- method: "POST",
83
- headers: {
84
- "Content-Type": "application/json",
85
- Authorization: `Bearer ${this.adminToken}`,
86
- },
87
- body: JSON.stringify(tokenBody),
88
- });
89
- if (!res.ok) {
90
- throw new Error(
91
- `task-store POST /tokens failed: ${res.status} ${res.statusText}`,
92
- );
93
- }
94
- const body =
95
- (await res.json()) as components["schemas"]["TaskTokenWithRaw"];
96
- return { id: body.id, rawToken: body.rawToken };
97
- }
98
-
99
- async revokeToken(id: string): Promise<void> {
100
- const res = await this.fetchFn(`${this.baseUrl}/tokens/${id}`, {
101
- method: "DELETE",
102
- headers: {
103
- Authorization: `Bearer ${this.adminToken}`,
104
- },
105
- });
106
- if (!res.ok && res.status !== 404) {
107
- throw new Error(
108
- `task-store DELETE /tokens/${id} failed: ${res.status} ${res.statusText}`,
109
- );
110
- }
111
- }
112
-
113
- async listTokensForAgent(agentId: string): Promise<{ id: string }[]> {
114
- const res = await this.fetchFn(`${this.baseUrl}/tokens`, {
115
- method: "GET",
116
- headers: {
117
- Authorization: `Bearer ${this.adminToken}`,
118
- },
119
- });
120
- if (!res.ok) {
121
- throw new Error(
122
- `task-store GET /tokens failed: ${res.status} ${res.statusText}`,
123
- );
124
- }
125
- const tokens = (await res.json()) as components["schemas"]["TaskToken"][];
126
- return tokens
127
- .filter((token) => token.agentId === agentId)
128
- .map((token) => ({ id: token.id }));
129
- }
130
- }
131
-
132
- // ─── No-op implementation ─────────────────────────────────────────────────────
133
-
134
- /**
135
- * No-op client used when SHIPWRIGHT_TASK_STORE_URL / SHIPWRIGHT_TASK_STORE_ADMIN_TOKEN
136
- * are not configured. mintToken returns empty strings so callers can gate on
137
- * the configured path without null-checking the return.
138
- */
139
- export class NoopTaskStoreProvisioningClient
140
- implements TaskStoreProvisioningClient
141
- {
142
- async mintToken(
143
- _label: string,
144
- _agentId?: string,
145
- ): Promise<{ id: string; rawToken: string }> {
146
- return { id: "", rawToken: "" };
147
- }
148
-
149
- async revokeToken(_id: string): Promise<void> {
150
- // intentionally a no-op
151
- }
152
-
153
- async listTokensForAgent(_agentId: string): Promise<{ id: string }[]> {
154
- return [];
155
- }
156
- }
@@ -1,85 +0,0 @@
1
- {
2
- "mintToken_success": {
3
- "status": 201,
4
- "body": { "id": "tok_abc123", "rawToken": "sts_raw_test_token_value" }
5
- },
6
- "mintToken_success_with_agentId": {
7
- "status": 201,
8
- "body": { "id": "tok_def456", "rawToken": "sts_raw_test_token_agent_scoped" }
9
- },
10
- "mintToken_500": {
11
- "status": 500,
12
- "body": { "error": "internal server error" }
13
- },
14
- "revokeToken_success": {
15
- "status": 204,
16
- "body": {}
17
- },
18
- "revokeToken_404": {
19
- "status": 404,
20
- "body": { "error": "not found" }
21
- },
22
- "revokeToken_500": {
23
- "status": 500,
24
- "body": { "error": "internal server error" }
25
- },
26
- "listTokens_mixedAgents": {
27
- "status": 200,
28
- "body": [
29
- {
30
- "id": "tok_agent1_a",
31
- "label": "agent-1 token a",
32
- "agentId": "agent-1",
33
- "createdAt": "2026-01-01T00:00:00.000Z"
34
- },
35
- {
36
- "id": "tok_agent2_a",
37
- "label": "agent-2 token",
38
- "agentId": "agent-2",
39
- "createdAt": "2026-01-01T00:00:00.000Z"
40
- },
41
- {
42
- "id": "tok_agent1_b",
43
- "label": "agent-1 token b",
44
- "agentId": "agent-1",
45
- "createdAt": "2026-01-02T00:00:00.000Z"
46
- },
47
- {
48
- "id": "tok_admin_noagent",
49
- "label": "admin token",
50
- "createdAt": "2026-01-01T00:00:00.000Z"
51
- },
52
- {
53
- "id": "tok_agent1_revoked",
54
- "label": "agent-1 already revoked",
55
- "agentId": "agent-1",
56
- "createdAt": "2026-01-01T00:00:00.000Z",
57
- "revokedAt": "2026-01-03T00:00:00.000Z"
58
- }
59
- ]
60
- },
61
- "listTokens_noMatches": {
62
- "status": 200,
63
- "body": [
64
- {
65
- "id": "tok_agent2_a",
66
- "label": "agent-2 token",
67
- "agentId": "agent-2",
68
- "createdAt": "2026-01-01T00:00:00.000Z"
69
- },
70
- {
71
- "id": "tok_admin_noagent",
72
- "label": "admin token",
73
- "createdAt": "2026-01-01T00:00:00.000Z"
74
- }
75
- ]
76
- },
77
- "listTokens_empty": {
78
- "status": 200,
79
- "body": []
80
- },
81
- "listTokens_500": {
82
- "status": 500,
83
- "body": { "error": "internal server error" }
84
- }
85
- }
@@ -1,67 +0,0 @@
1
- /**
2
- * AGR-1.9 — SpecialtyRegistry against the platform's fixture specialty.
3
- *
4
- * Real filesystem reads, no injected reader. Nothing here depends on a product
5
- * specialty, so it runs in the extracted tree as well as the monorepo.
6
- */
7
-
8
- import { describe, expect, it } from "bun:test";
9
- import { existsSync } from "node:fs";
10
- import { join } from "node:path";
11
- import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
12
-
13
- // agent/src/fixtures in the monorepo, runtime/fixtures in the extracted layout.
14
- const FIXTURE_SPECIALTIES_DIR = [
15
- join(import.meta.dir, "..", "..", "agent", "src", "fixtures", "specialties"),
16
- join(import.meta.dir, "..", "..", "runtime", "fixtures", "specialties"),
17
- ].find((dir) => existsSync(dir)) as string;
18
-
19
- function fixtureRegistry(warn: (m: string) => void = () => {}) {
20
- return new SpecialtyRegistry({
21
- typesDir: FIXTURE_SPECIALTIES_DIR,
22
- defaultTypeName: "fixture",
23
- warn,
24
- });
25
- }
26
-
27
- describe("SpecialtyRegistry with the fixture specialty", () => {
28
- it("loads the fixture manifest from disk", () => {
29
- const manifest = fixtureRegistry().getManifest("fixture");
30
- expect(manifest.metadata.name).toBe("fixture");
31
- expect(manifest.identity.templatesDir).toBe("templates/");
32
- });
33
-
34
- it("lists the fixture in the type picker", () => {
35
- expect(fixtureRegistry().listTypes()).toEqual([
36
- { name: "fixture", displayName: "Fixture Agent" },
37
- ]);
38
- });
39
-
40
- it("falls back to the fixture default for an unknown type, with a warning", () => {
41
- const warnings: string[] = [];
42
- const manifest = fixtureRegistry((m) => warnings.push(m)).getManifest(
43
- "renamed-type",
44
- );
45
- expect(manifest.metadata.name).toBe("fixture");
46
- expect(warnings).toHaveLength(1);
47
- });
48
-
49
- it("tryGetManifest does not fall back", () => {
50
- expect(fixtureRegistry().tryGetManifest("renamed-type")).toBeUndefined();
51
- });
52
-
53
- it("warns and returns an empty-crons manifest when the default specialty is not on disk", () => {
54
- const warnings: string[] = [];
55
- const registry = new SpecialtyRegistry({
56
- typesDir: FIXTURE_SPECIALTIES_DIR,
57
- defaultTypeName: "not-there",
58
- warn: (m) => warnings.push(m),
59
- });
60
- const manifest = registry.getManifest("fixture-or-anything");
61
- expect(manifest.crons).toEqual([]);
62
- expect(manifest.plugins).toEqual([]);
63
- expect(warnings.join("\n")).toMatch(
64
- /SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
65
- );
66
- });
67
- });
@@ -1,285 +0,0 @@
1
- /**
2
- * admin/src/specialty-manifest-loader.ts
3
- *
4
- * SpecialtyRegistry — resolves an agent's stored `specialty` to its parsed
5
- * Specialty manifest by loading `{typesDir}/{specialty}/manifest.yaml` from
6
- * disk (typesDir defaults to the shipped agent-types/) and validating it via
7
- * parseSpecialtyManifest().
8
- *
9
- * This is the runtime source of truth for an agent's default system crons
10
- * (and, in time, its default plugins/tools/env contract). It replaces the
11
- * former hardcoded SYSTEM_CRONS array in admin/src/system-crons.ts.
12
- *
13
- * Defaults: the default type is `specialist` and the type directory is the
14
- * shipped `agent-types/` (both resolved by @app-vitals/agent-lib/default-specialty,
15
- * the single shared helper; SHIPWRIGHT_DEFAULT_AGENT_TYPE /
16
- * SHIPWRIGHT_SPECIALTIES_DIR override them).
17
- *
18
- * Fallback contract: getManifest() never throws. An unknown `specialty` (no
19
- * matching directory under the type dir) resolves to the default type's
20
- * manifest with a logged warning. If even the default cannot be loaded (bad
21
- * env override, missing/invalid manifest) it warns and returns a built-in
22
- * minimal manifest with no crons/plugins, so the boot-path
23
- * POST /agents/:id/crons/reconcile route never 5xxs over configuration.
24
- *
25
- * The class takes an injectable reader/warner so unit tests can drive it
26
- * without touching the filesystem (see agent-cron-jobs.unit.test.ts).
27
- */
28
-
29
- import { readdirSync, readFileSync } from "node:fs";
30
- import { join } from "node:path";
31
- import {
32
- DEFAULT_SPECIALTY_NAME,
33
- resolveDefaultSpecialtyName,
34
- resolveSpecialtiesDir,
35
- } from "@app-vitals/agent-lib/default-specialty";
36
- import {
37
- parseSpecialtyManifest,
38
- type SpecialtyManifest,
39
- } from "./specialty-registry.ts";
40
-
41
- export {
42
- DEFAULT_SPECIALTY_NAME,
43
- resolveDefaultSpecialtyName,
44
- resolveSpecialtiesDir,
45
- };
46
-
47
- /**
48
- * Built-in last-resort manifest: used only when the default type itself cannot
49
- * be loaded. Declares nothing (no crons, plugins, tools) so it never seeds
50
- * anything unexpected.
51
- */
52
- const BUILT_IN_FALLBACK_MANIFEST_YAML = `apiVersion: shipwright.dev/v1alpha1
53
- kind: Specialty
54
- metadata:
55
- name: ${DEFAULT_SPECIALTY_NAME}
56
- displayName: Specialist Agent
57
- description: Built-in fallback used when the default specialty manifest cannot be loaded.
58
- version: 0.0.0
59
- skills: []
60
- identity:
61
- templatesDir: workspace/
62
- crons: []
63
- plugins: []
64
- tools: []
65
- env:
66
- required: []
67
- optional: []
68
- members: []
69
- repos: []
70
- chat: true
71
- voice: true
72
- `;
73
-
74
- /** Reads a manifest's raw YAML text for a type; throws if it does not exist. */
75
- type ManifestReader = (specialty: string) => string;
76
-
77
- /** Sink for the unknown-specialty warning (defaults to console.warn). */
78
- type WarnFn = (message: string) => void;
79
-
80
- /**
81
- * Lists the name of every discoverable specialty directory (e.g. the
82
- * names of the subdirectories under specialties/). Used by listTypes() to
83
- * discover candidates before resolving each one's manifest via `read`.
84
- */
85
- type TypeDirLister = () => string[];
86
-
87
- /** A discovered specialty, as surfaced to the new-specialty picker. */
88
- export interface SpecialtyOption {
89
- name: string;
90
- displayName: string;
91
- }
92
-
93
- /** Default reader: loads {typesDir}/{specialty}/manifest.yaml from disk. */
94
- export function defaultManifestReader(
95
- specialty: string,
96
- typesDir: string = resolveSpecialtiesDir(),
97
- ): string {
98
- return readFileSync(join(typesDir, specialty, "manifest.yaml"), "utf-8");
99
- }
100
-
101
- /** Default lister: the directory names under the specialties dir on disk. */
102
- export function defaultTypeDirLister(
103
- typesDir: string = resolveSpecialtiesDir(),
104
- ): string[] {
105
- return readdirSync(typesDir, { withFileTypes: true })
106
- .filter((entry) => entry.isDirectory())
107
- .map((entry) => entry.name);
108
- }
109
-
110
- /**
111
- * Interface the reconcile path depends on — kept narrow so tests can supply a
112
- * trivial double without constructing the real disk-backed registry.
113
- */
114
- export interface SpecialtyManifestResolver {
115
- /**
116
- * Resolve `specialty` to its parsed manifest. Falls back to the default
117
- * type's manifest (with a warning) when the type is unknown, and to a
118
- * built-in empty-crons manifest (with a warning) if the default cannot load
119
- * either. Never throws.
120
- */
121
- getManifest(specialty: string): SpecialtyManifest;
122
- /**
123
- * Resolve `specialty` to its parsed manifest WITHOUT falling back to
124
- * the default type — returns undefined for an unknown type instead (the one exception
125
- * is the built-in default type when no specialty is discoverable, matching the
126
- * picker's fallback option). No warning is
127
- * logged (an unknown type here is an expected, callers-handle-it outcome,
128
- * not a degraded-boot condition). Used by POST /admin/agents to reject an
129
- * unknown requested `type` with 400 rather than silently seeding a
130
- * default-type agent under a different label.
131
- */
132
- tryGetManifest(specialty: string): SpecialtyManifest | undefined;
133
- /**
134
- * Discover every specialty directory (via the injected `list` fn) and
135
- * resolve each to its {name, displayName} for populating the new-agent
136
- * type picker. A directory whose manifest fails to load or parse is
137
- * skipped (not thrown) — the picker still renders every other valid type
138
- * rather than 500ing the whole /admin/agents/new page over one bad
139
- * manifest. Order matches the underlying `list` fn's order. Never throws:
140
- * a missing type dir is treated as empty (with a warning), and when no type
141
- * is discoverable the built-in default option is returned.
142
- */
143
- listTypes(): SpecialtyOption[];
144
- /**
145
- * True when an AdminExtension declares `specialty` via agentTypes() — a type
146
- * with no manifest that POST /admin/agents must still accept. Optional: the
147
- * disk-backed registry has none.
148
- */
149
- hasExtensionType?(specialty: string): boolean;
150
- }
151
-
152
- export class SpecialtyRegistry implements SpecialtyManifestResolver {
153
- private readonly read: ManifestReader;
154
- private readonly warn: WarnFn;
155
- private readonly list: TypeDirLister;
156
- private readonly defaultTypeName: string;
157
- private readonly typesDir: string;
158
- private readonly cache = new Map<string, SpecialtyManifest>();
159
-
160
- /**
161
- * `typesDir` and `defaultTypeName` default to SHIPWRIGHT_SPECIALTIES_DIR /
162
- * SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to the shipped agent-types/
163
- * and "specialist". An injected `read`/`list` takes precedence over `typesDir`.
164
- */
165
- constructor(
166
- opts: {
167
- read?: ManifestReader;
168
- warn?: WarnFn;
169
- list?: TypeDirLister;
170
- typesDir?: string;
171
- defaultTypeName?: string;
172
- } = {},
173
- ) {
174
- const typesDir = opts.typesDir ?? resolveSpecialtiesDir();
175
- this.typesDir = typesDir;
176
- this.read = opts.read ?? ((name) => defaultManifestReader(name, typesDir));
177
- this.warn = opts.warn ?? ((m) => console.warn(m));
178
- this.list = opts.list ?? (() => defaultTypeDirLister(typesDir));
179
- this.defaultTypeName =
180
- opts.defaultTypeName ?? resolveDefaultSpecialtyName();
181
- }
182
-
183
- getManifest(specialty: string): SpecialtyManifest {
184
- const resolved = this.tryLoadSafe(specialty);
185
- if (resolved) return resolved;
186
-
187
- this.warn(
188
- `SpecialtyRegistry: unknown agent specialty "${specialty}" — ` +
189
- `falling back to "${this.defaultTypeName}" manifest`,
190
- );
191
- if (specialty !== this.defaultTypeName) {
192
- const fallback = this.tryLoadSafe(this.defaultTypeName);
193
- if (fallback) return fallback;
194
- }
195
- // Even the default is unloadable (bad SHIPWRIGHT_SPECIALTIES_DIR /
196
- // SHIPWRIGHT_DEFAULT_AGENT_TYPE override). Degrade to an empty manifest
197
- // rather than failing boot; the warning names the knobs to fix.
198
- this.warn(
199
- `SpecialtyRegistry: the default specialty "${this.defaultTypeName}" could not be loaded from "${this.typesDir}" — ` +
200
- "using a built-in manifest with no crons. Check SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE.",
201
- );
202
- return parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
203
- }
204
-
205
- /**
206
- * Resolve `specialty` without falling back to the default type — see the interface
207
- * doc comment. Thin public wrapper around the existing tryLoad() used
208
- * internally by getManifest().
209
- */
210
- tryGetManifest(specialty: string): SpecialtyManifest | undefined {
211
- const manifest = this.tryLoad(specialty);
212
- if (manifest || specialty !== DEFAULT_SPECIALTY_NAME) return manifest;
213
- // The picker offers the built-in default when nothing is discoverable, so
214
- // creating an agent with it must resolve too.
215
- return this.discoverOptions().length === 0
216
- ? parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML)
217
- : undefined;
218
- }
219
-
220
- listTypes(): SpecialtyOption[] {
221
- const options = this.discoverOptions();
222
- if (options.length > 0) return options;
223
- // Nothing discoverable: offer the built-in default so the picker is never empty.
224
- const fallback = parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
225
- return [
226
- {
227
- name: fallback.metadata.name,
228
- displayName: fallback.metadata.displayName,
229
- },
230
- ];
231
- }
232
-
233
- /** Every loadable specialty found on disk; empty when the dir is missing or has none. */
234
- private discoverOptions(): SpecialtyOption[] {
235
- let specialties: string[];
236
- try {
237
- specialties = this.list();
238
- } catch (err) {
239
- // Missing dir (e.g. npm consumers, where agent-types/ is not shipped).
240
- this.warn(
241
- `SpecialtyRegistry: could not list specialties under "${this.typesDir}" — ` +
242
- `treating as empty (${err instanceof Error ? err.message : String(err)})`,
243
- );
244
- specialties = [];
245
- }
246
- const options: SpecialtyOption[] = [];
247
- for (const specialty of specialties) {
248
- const manifest = this.tryLoad(specialty);
249
- if (!manifest) continue;
250
- options.push({
251
- name: manifest.metadata.name,
252
- displayName: manifest.metadata.displayName,
253
- });
254
- }
255
- return options;
256
- }
257
-
258
- /** tryLoad, but an invalid (unparseable) manifest also yields undefined. */
259
- private tryLoadSafe(specialty: string): SpecialtyManifest | undefined {
260
- try {
261
- return this.tryLoad(specialty);
262
- } catch {
263
- return undefined;
264
- }
265
- }
266
-
267
- /** Load + cache a manifest, returning undefined only if the file is missing. */
268
- private tryLoad(specialty: string): SpecialtyManifest | undefined {
269
- const cached = this.cache.get(specialty);
270
- if (cached) return cached;
271
- let raw: string;
272
- try {
273
- raw = this.read(specialty);
274
- } catch {
275
- return undefined;
276
- }
277
- return this.parseAndCache(specialty, raw);
278
- }
279
-
280
- private parseAndCache(specialty: string, raw: string): SpecialtyManifest {
281
- const manifest = parseSpecialtyManifest(raw);
282
- this.cache.set(specialty, manifest);
283
- return manifest;
284
- }
285
- }