@app-vitals/agent-admin 0.15.0 → 0.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.15.0",
46
+ "@app-vitals/agent-lib": "0.16.1",
47
47
  "@hono/zod-openapi": "^1.0.0",
48
48
  "@prisma/adapter-pg": "^7.10.0",
49
49
  "@prisma/client": "^7.10.0",
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Shared rules for self-serve agent creation by account users (SSP-4.2),
5
5
  * used by both the admin UI form handler and the JSON API route so the two
6
- * cannot drift: account users always create "coding" agents stamped with
7
- * THEIR account (never a client-supplied one), and quota / account-status
8
- * failures surface as clear messages.
6
+ * cannot drift: account users always create the self-serve specialty (see
7
+ * getSelfServeSpecialty) stamped with THEIR account (never a client-supplied
8
+ * one), and quota / account-status failures surface as clear messages.
9
9
  */
10
10
 
11
11
  import type { CallerScope } from "./caller-scope.ts";
@@ -14,7 +14,7 @@ import { resolveDefaultSpecialtyName } from "./specialty-manifest-loader.ts";
14
14
  /**
15
15
  * The only specialty account users may create: SHIPWRIGHT_SELF_SERVE_AGENT_TYPE
16
16
  * when set, else the platform's default specialty (SHIPWRIGHT_DEFAULT_AGENT_TYPE,
17
- * "coding" when unset).
17
+ * "specialist" when unset).
18
18
  */
19
19
  export function getSelfServeSpecialty(
20
20
  env: Record<string, string | undefined> = process.env,
@@ -7,8 +7,8 @@ import { describe, expect, it } from "bun:test";
7
7
  import { getSelfServeSpecialty } from "./account-agent-create.ts";
8
8
 
9
9
  describe("getSelfServeSpecialty", () => {
10
- it("defaults to coding", () => {
11
- expect(getSelfServeSpecialty({})).toBe("coding");
10
+ it("defaults to specialist", () => {
11
+ expect(getSelfServeSpecialty({})).toBe("specialist");
12
12
  expect(
13
13
  getSelfServeSpecialty({ SHIPWRIGHT_DEFAULT_AGENT_TYPE: "fixture" }),
14
14
  ).toBe("fixture");
package/src/admin-boot.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  type AdminExtension,
66
66
  type AdminExtensionContext,
67
67
  collectServiceIntegrations,
68
+ collectSpecialtyDefaults,
68
69
  mountRouteContributions,
69
70
  resolveAdminExtensions,
70
71
  startBackgroundPasses,
@@ -178,7 +179,15 @@ export async function startAdmin(
178
179
  undefined,
179
180
  anthropicFederationEnabled,
180
181
  );
181
- const agentCronJobService = new AgentCronJobService(prisma);
182
+ // Extension-supplied specialty defaults (crons/tools/plugins), consulted by
183
+ // the cron reconcile and create-time seeding before the manifest.
184
+ const specialtyDefaults = collectSpecialtyDefaults(extensions);
185
+ const agentCronJobService = new AgentCronJobService(
186
+ prisma,
187
+ undefined,
188
+ undefined,
189
+ specialtyDefaults,
190
+ );
182
191
  const agentCronRunService = new AgentCronRunService(prisma);
183
192
  const agentToolService = new AgentToolService(prisma);
184
193
  const agentTokenService = new AgentTokenService(prisma);
@@ -450,6 +459,7 @@ export async function startAdmin(
450
459
  agentMemberService,
451
460
  callerScopeResolver,
452
461
  specialtyRegistry: new SpecialtyRegistry(),
462
+ specialtyDefaults,
453
463
  agentChatTokenService,
454
464
  agentGitHubInstallationsService,
455
465
  prisma,
@@ -541,6 +551,7 @@ export async function startAdmin(
541
551
  agentService,
542
552
  provisioner,
543
553
  anthropicFederationEnabled,
554
+ specialtyDefaults,
544
555
  serviceIntegrations,
545
556
  chatService: deletionChatService,
546
557
  slack: slackClient,
@@ -24,6 +24,12 @@
24
24
  * agentDetailCards(ctx) — extra cards on the agent detail page, each placed
25
25
  * in the header or the Configuration group
26
26
  * backgroundPasses(ctx) — interval loops, started after the platform sweepers
27
+ * specialtyDefaults(name) — a specialty's default crons/tools/plugins. Like
28
+ * serviceIntegrations it needs no context and is
29
+ * collected before the services are built; the cron
30
+ * reconcile and create-time seeding consult it
31
+ * before the specialty manifest, which remains the
32
+ * fallback when no extension returns anything
27
33
  *
28
34
  * openApi() is static (no context): scripts/generate-admin-spec.ts calls it to
29
35
  * fold extension routes/schemas into admin/openapi.json without booting.
@@ -52,6 +58,10 @@ import type { AgentPhaseMethodologyServiceLike } from "./api.ts";
52
58
  import type { createCallerScopeResolver } from "./caller-scope.ts";
53
59
  import type { PushService } from "./push-service.ts";
54
60
  import type { ServiceIntegration } from "./service-integration.ts";
61
+ import type {
62
+ SpecialtyDefaults,
63
+ SpecialtyDefaultsResolver,
64
+ } from "./specialty-defaults.ts";
55
65
 
56
66
  /** The platform services an extension may build on. */
57
67
  export interface AdminPlatformServices {
@@ -168,6 +178,8 @@ export interface AdminExtension {
168
178
  name?: string;
169
179
  /** See the header: env-only, resolved before the provisioner is built. */
170
180
  serviceIntegrations?(env: NodeJS.ProcessEnv): ServiceIntegration[];
181
+ /** See the header: undefined means "no opinion" — the manifest applies. */
182
+ specialtyDefaults?(specialty: string): SpecialtyDefaults | undefined;
171
183
  services?(ctx: AdminExtensionContext): AdminExtensionServices;
172
184
  apiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
173
185
  adminUiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
@@ -344,6 +356,23 @@ export function collectServiceIntegrations(
344
356
  return extensions.flatMap((ext) => ext.serviceIntegrations?.(env) ?? []);
345
357
  }
346
358
 
359
+ /**
360
+ * Builds the resolver the cron reconcile and create-time seeding consult
361
+ * before the manifest. The first extension returning defaults wins; with none
362
+ * registered (or none answering) it returns undefined and the manifest applies.
363
+ */
364
+ export function collectSpecialtyDefaults(
365
+ extensions: readonly AdminExtension[],
366
+ ): SpecialtyDefaultsResolver {
367
+ return (specialty) => {
368
+ for (const ext of extensions) {
369
+ const defaults = ext.specialtyDefaults?.(specialty);
370
+ if (defaults) return defaults;
371
+ }
372
+ return undefined;
373
+ };
374
+ }
375
+
347
376
  /** Mounts each contribution on root, in order. */
348
377
  export function mountRouteContributions(
349
378
  root: AnyHono,
@@ -9,6 +9,7 @@ import {
9
9
  applyOpenApiContributions,
10
10
  collectOpenApiContributions,
11
11
  collectServiceIntegrations,
12
+ collectSpecialtyDefaults,
12
13
  mountRouteContributions,
13
14
  renderAgentDetailCardSlots,
14
15
  renderAgentDetailCards,
@@ -530,3 +531,21 @@ describe("renderAgentDetailCardSlots", () => {
530
531
  expect(rendered).toBe(1);
531
532
  });
532
533
  });
534
+
535
+ describe("collectSpecialtyDefaults", () => {
536
+ it("returns undefined with no extensions or hookless extensions", () => {
537
+ expect(collectSpecialtyDefaults([])("coding")).toBeUndefined();
538
+ expect(
539
+ collectSpecialtyDefaults([{ name: "bare" }])("coding"),
540
+ ).toBeUndefined();
541
+ });
542
+
543
+ it("returns the first extension's defaults that answers for the specialty", () => {
544
+ const resolve = collectSpecialtyDefaults([
545
+ { specialtyDefaults: (s) => (s === "a" ? { tools: ["A"] } : undefined) },
546
+ { specialtyDefaults: () => ({ tools: ["B"] }) },
547
+ ]);
548
+ expect(resolve("a")).toEqual({ tools: ["A"] });
549
+ expect(resolve("b")).toEqual({ tools: ["B"] });
550
+ });
551
+ });
@@ -1523,7 +1523,7 @@ describe("admin UI — account users create / delete agents (SSP-4.2)", () => {
1523
1523
  expect(res.status).toBe(403);
1524
1524
  });
1525
1525
 
1526
- it("POST /admin/agents — account user's agent is stamped with their account and forced to coding", async () => {
1526
+ it("POST /admin/agents — account user's agent is stamped with their account and forced to the self-serve specialty", async () => {
1527
1527
  const { deps, created } = depsCapturingCreate("acct-a");
1528
1528
  const res = await createAdminUIApp(deps).request(
1529
1529
  "/admin/agents",
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { PROGRESS_LABELS } from "@app-vitals/agent-lib/progress-phases";
14
+ import { getSelfServeSpecialty } from "./account-agent-create.ts";
14
15
  import { renderAdminPage } from "./admin-ui-layout.ts";
15
16
  import {
16
17
  BREAKPOINT_MOBILE_MAX,
@@ -18,7 +19,6 @@ import {
18
19
  renderAdminToolbar,
19
20
  } from "./admin-ui-styles.ts";
20
21
  import type { ManualStep } from "./agent-deletion-checklist.ts";
21
- import type { SpecialtyOption } from "./specialty-manifest-loader.ts";
22
22
  import { isAudioFilename } from "./attachment-validation.ts";
23
23
  import { parseChatMarkers } from "./chat-markers.ts";
24
24
  import type {
@@ -28,6 +28,7 @@ import type {
28
28
  ThreadStats,
29
29
  } from "./http-chat-client.ts";
30
30
  import { renderPushToggle } from "./push-toggle.ts";
31
+ import type { SpecialtyOption } from "./specialty-manifest-loader.ts";
31
32
 
32
33
  // ─── Helpers ──────────────────────────────────────────────────────────────────
33
34
 
@@ -79,7 +80,7 @@ export interface AgentDetail {
79
80
  * Rendered as a checkbox on both the create and edit forms.
80
81
  */
81
82
  restrictSlackToMembers: boolean;
82
- /** The agent's type name (e.g. "coding"), resolved at creation time. */
83
+ /** The agent's type name (e.g. "specialist"), resolved at creation time. */
83
84
  specialty: string;
84
85
  /**
85
86
  * Required env keys declared by the agent's type manifest with no
@@ -487,7 +488,7 @@ export function renderNewLocalAgentPage(
487
488
  /**
488
489
  * Set for self-serve account users (SSP-4.2): shows the account's
489
490
  * remaining agent quota and the prerequisites, and pins the type to
490
- * "coding" (the server forces it regardless).
491
+ * the self-serve specialty (the server forces it regardless).
491
492
  */
492
493
  selfServe?: { remaining: number; max: number; contactEmail: string };
493
494
  },
@@ -566,7 +567,7 @@ export function renderNewLocalAgentPage(
566
567
  </div>`
567
568
  : "";
568
569
  const typeFieldHtml = selfServe
569
- ? `<input type="hidden" name="type" value="coding" />`
570
+ ? `<input type="hidden" name="type" value="${escapeHtml(getSelfServeSpecialty())}" />`
570
571
  : `<div class="form-group">
571
572
  <label class="form-label" for="type">Specialty <span style="color:#ef4444">*</span></label>
572
573
  <select id="type" name="type" class="form-input" required>
@@ -6,7 +6,7 @@
6
6
  * No I/O, no Hono, no HTTP — pure string → string.
7
7
  */
8
8
 
9
- import { describe, expect, test } from "bun:test";
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
10
  import { renderZeroQuotaNotice } from "./admin-ui-account-pages.ts";
11
11
  import {
12
12
  type AgentDetail,
@@ -4259,12 +4259,40 @@ describe("renderNewLocalAgentPage — self-serve account user (SSP-4.2)", () =>
4259
4259
  expect(html).toContain("Claude API key");
4260
4260
  });
4261
4261
 
4262
- test("forces the coding type via a hidden input instead of a type select", () => {
4263
- const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4264
- selfServe,
4262
+ describe("hidden type input", () => {
4263
+ const saved = {
4264
+ selfServe: process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE,
4265
+ defaultType: process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE,
4266
+ };
4267
+ const restore = (key: string, value: string | undefined) => {
4268
+ if (value === undefined) delete process.env[key];
4269
+ else process.env[key] = value;
4270
+ };
4271
+ afterEach(() => {
4272
+ restore("SHIPWRIGHT_SELF_SERVE_AGENT_TYPE", saved.selfServe);
4273
+ restore("SHIPWRIGHT_DEFAULT_AGENT_TYPE", saved.defaultType);
4274
+ });
4275
+
4276
+ test("carries the SHIPWRIGHT_SELF_SERVE_AGENT_TYPE value instead of a type select", () => {
4277
+ process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE = "base";
4278
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4279
+ selfServe,
4280
+ });
4281
+ expect(html).toContain('<input type="hidden" name="type" value="base"');
4282
+ expect(html).not.toContain('name="type" value="coding"');
4283
+ expect(html).not.toMatch(/<select[^>]*name="type"/);
4284
+ });
4285
+
4286
+ test("falls back to the resolved default specialty when the env is unset", () => {
4287
+ delete process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE;
4288
+ delete process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE;
4289
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4290
+ selfServe,
4291
+ });
4292
+ expect(html).toContain(
4293
+ '<input type="hidden" name="type" value="specialist"',
4294
+ );
4265
4295
  });
4266
- expect(html).toContain('<input type="hidden" name="type" value="coding"');
4267
- expect(html).not.toMatch(/<select[^>]*name="type"/);
4268
4296
  });
4269
4297
 
4270
4298
  test("at zero remaining, explains the limit and points at the contact email", () => {
package/src/admin-ui.ts CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  type SpecialtyManifestResolver,
79
79
  SpecialtyRegistry,
80
80
  } from "./specialty-manifest-loader.ts";
81
+ import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
81
82
  import type { AgentService } from "./agents.ts";
82
83
  import { createAgent, describeRepoNameCollision } from "./agents.ts";
83
84
  import { AnthropicCredentialsBlockedError } from "./anthropic-credential-guard.ts";
@@ -352,6 +353,8 @@ export interface AdminUIDeps {
352
353
  * already used by agents-api.ts's AdminDeps.
353
354
  */
354
355
  specialtyRegistry?: SpecialtyManifestResolver;
356
+ /** Extension-supplied specialty defaults, consulted before the manifest. */
357
+ specialtyDefaults?: SpecialtyDefaultsResolver;
355
358
  /**
356
359
  * Cleanup deps for the full delete-agent orchestration (POST
357
360
  * /admin/agents/:id/delete → deleteAgentFully()). Matches
@@ -782,6 +785,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
782
785
  accountCreatedNotifier,
783
786
  accountServices,
784
787
  specialtyRegistry = new SpecialtyRegistry(),
788
+ specialtyDefaults,
785
789
  callerScopeResolver = memberOnlyCallerScopeResolver(agentMemberService),
786
790
  serviceIntegrations,
787
791
  chatService,
@@ -1507,7 +1511,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1507
1511
  return c.redirect("/admin/agents/new", 302);
1508
1512
  }
1509
1513
  const connectSlack = connectSlackRaw === "true";
1510
- // SSP-4.2: account users always create "coding" agents in their own
1514
+ // SSP-4.2: account users always create self-serve-specialty agents in their own
1511
1515
  // account — any submitted type/accountId is ignored.
1512
1516
  if (accountId !== null) specialty = getSelfServeSpecialty();
1513
1517
  // Absent/unrecognized runtime means self-hosted — the historical behavior
@@ -1557,6 +1561,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1557
1561
  agentCronJobService,
1558
1562
  provisioner,
1559
1563
  specialtyRegistry,
1564
+ specialtyDefaults,
1560
1565
  anthropicFederationEnabled: deps.anthropicFederationEnabled,
1561
1566
  },
1562
1567
  {
@@ -11,6 +11,10 @@
11
11
 
12
12
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
13
13
  import type { PrismaClient } from "../prisma/client/client.ts";
14
+ import {
15
+ type AdminExtension,
16
+ collectSpecialtyDefaults,
17
+ } from "./admin-extension.ts";
14
18
  import { AgentCronJobService } from "./agent-cron-jobs.ts";
15
19
  import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
16
20
  import { createAdminPrismaClient } from "./prisma-client.ts";
@@ -581,3 +585,93 @@ describeOrSkip("AgentCronJobService (integration)", () => {
581
585
  expect(await service.list(agentId)).toEqual([]);
582
586
  });
583
587
  });
588
+
589
+ // A fixture extension supplying defaults, wired the way admin-boot.ts does.
590
+ const fixtureExtension: AdminExtension = {
591
+ name: "fixture",
592
+ specialtyDefaults: (specialty) =>
593
+ specialty === FIXTURE_TYPE
594
+ ? {
595
+ crons: [
596
+ {
597
+ name: "ext-loop",
598
+ schedule: "*/10 * * * *",
599
+ prompt: "/ext:loop",
600
+ enabled: true,
601
+ },
602
+ {
603
+ name: "ext-child",
604
+ schedule: "5 * * * *",
605
+ prompt: "/ext:child",
606
+ enabled: false,
607
+ parentCron: "ext-loop",
608
+ },
609
+ ],
610
+ }
611
+ : undefined,
612
+ };
613
+
614
+ describeOrSkip("AgentCronJobService specialtyDefaults (integration)", () => {
615
+ let prisma: PrismaClient;
616
+ let service: AgentCronJobService;
617
+
618
+ beforeEach(async () => {
619
+ prisma = makePrisma();
620
+ await prisma.agentToken.deleteMany();
621
+ await prisma.agentCronJob.deleteMany();
622
+ await prisma.agentTool.deleteMany();
623
+ await prisma.agentEnv.deleteMany();
624
+ await prisma.agent.deleteMany();
625
+ service = new AgentCronJobService(
626
+ prisma,
627
+ undefined,
628
+ fixtureResolver,
629
+ collectSpecialtyDefaults([fixtureExtension]),
630
+ );
631
+ });
632
+
633
+ afterEach(async () => {
634
+ await prisma.$disconnect();
635
+ });
636
+
637
+ it("reconcile seeds the extension's crons instead of the manifest's", async () => {
638
+ const agentId = await createAgent(prisma);
639
+ const result = await service.reconcileSystemCrons(agentId);
640
+ expect(result).toEqual({ created: 2, updated: 0, deleted: 0 });
641
+ const jobs = await service.list(agentId);
642
+ expect(jobs.map((j) => j.name).sort()).toEqual(["ext-child", "ext-loop"]);
643
+ const loop = jobs.find((j) => j.name === "ext-loop");
644
+ const child = jobs.find((j) => j.name === "ext-child");
645
+ expect(child?.parentCronId).toBe(loop?.id as string);
646
+ expect(child?.enabled).toBe(false);
647
+ });
648
+
649
+ it("re-reconcile keeps ids and enabled state and deletes orphaned system crons", async () => {
650
+ const agentId = await createAgent(prisma);
651
+ await service.reconcileSystemCrons(agentId);
652
+ const before = await service.list(agentId);
653
+ const loop = before.find((j) => j.name === "ext-loop");
654
+ await prisma.agentCronJob.update({
655
+ where: { id: loop?.id as string },
656
+ data: { enabled: false },
657
+ });
658
+ await prisma.agentCronJob.create({
659
+ data: {
660
+ agentId,
661
+ name: "stale-system",
662
+ system: true,
663
+ schedule: "* * * * *",
664
+ prompt: "x",
665
+ },
666
+ });
667
+
668
+ const result = await service.reconcileSystemCrons(agentId);
669
+
670
+ expect(result).toEqual({ created: 0, updated: 2, deleted: 1 });
671
+ const after = await service.list(agentId);
672
+ const loopAfter = after.find((j) => j.name === "ext-loop");
673
+ expect(loopAfter?.id).toBe(loop?.id as string);
674
+ expect(loopAfter?.enabled).toBe(false);
675
+ expect(after.some((j) => j.name === "stale-system")).toBe(false);
676
+ });
677
+ });
@@ -8,16 +8,17 @@
8
8
  */
9
9
 
10
10
  import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
11
+ import { type Clock, SystemClock } from "./clock.ts";
12
+ import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
13
+ import type {
14
+ SpecialtyDefaultCron,
15
+ SpecialtyDefaultsResolver,
16
+ } from "./specialty-defaults.ts";
11
17
  import {
12
18
  type SpecialtyManifestResolver,
13
19
  SpecialtyRegistry,
14
- missingDefaultSpecialtyError,
15
- resolveDefaultSpecialtyName,
16
- resolveSpecialtiesDir,
17
20
  } from "./specialty-manifest-loader.ts";
18
21
  import type { SpecialtyManifest } from "./specialty-registry.ts";
19
- import { type Clock, SystemClock } from "./clock.ts";
20
- import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
21
22
 
22
23
  /**
23
24
  * The shape of a single cron template resolved from a specialty's manifest.
@@ -87,6 +88,7 @@ export class AgentCronJobService {
87
88
  private prisma: PrismaClient,
88
89
  private clock: Clock = SystemClock(),
89
90
  private specialtyRegistry: SpecialtyManifestResolver = new SpecialtyRegistry(),
91
+ private specialtyDefaults: SpecialtyDefaultsResolver = () => undefined,
90
92
  ) {}
91
93
 
92
94
  /**
@@ -443,11 +445,10 @@ export class AgentCronJobService {
443
445
  * The cron list is resolved from the agent's type manifest — the agent's
444
446
  * stored `specialty` is looked up via the injected SpecialtyRegistry's
445
447
  * `tryGetManifest()` (the parsed `specialties/{specialty}/manifest.yaml`),
446
- * deliberately WITHOUT the "coding" fallback: a non-default type with no
447
- * loadable manifest logs an error and reconciles nothing (no seeding of
448
- * coding crons, no deletion of existing system crons), while a "coding"
449
- * manifest that cannot load throws — the universal fallback failing is a
450
- * startup bug. This replaced the former hardcoded
448
+ * deliberately WITHOUT the default-type fallback: any type (the default
449
+ * included) with no loadable manifest logs an error and reconciles nothing
450
+ * (no seeding of another type's crons, no deletion of existing system
451
+ * crons). This replaced the former hardcoded
451
452
  * SYSTEM_CRONS array as the source of truth (ATS-3.2).
452
453
  *
453
454
  * Two passes so parent/child links resolve regardless of the manifest's
@@ -483,9 +484,9 @@ export class AgentCronJobService {
483
484
  async reconcileSystemCrons(
484
485
  agentId: string,
485
486
  ): Promise<{ created: number; updated: number; deleted: number }> {
486
- // Resolve the agent's type manifest → its declared cron list. No "coding"
487
- // fallback: seeding enabled coding crons on a non-coding agent is worse
488
- // than leaving its crons untouched.
487
+ // Resolve the agent's type manifest → its declared cron list. No default-type
488
+ // fallback: seeding another type's crons on an agent is worse than
489
+ // leaving its crons untouched.
489
490
  const agent = await this.prisma.agent.findUnique({
490
491
  where: { id: agentId },
491
492
  select: { specialty: true },
@@ -494,22 +495,19 @@ export class AgentCronJobService {
494
495
  throw new NotFoundError(`agent ${agentId} not found`);
495
496
  }
496
497
  const { specialty } = agent;
497
- const manifest = this.specialtyRegistry.tryGetManifest(specialty);
498
- if (!manifest) {
499
- const defaultName = resolveDefaultSpecialtyName();
500
- if (specialty === defaultName) {
501
- throw missingDefaultSpecialtyError(
502
- defaultName,
503
- resolveSpecialtiesDir(),
504
- new Error(`reconcileSystemCrons for agent ${agentId}`),
505
- );
506
- }
498
+ // Extension-supplied crons win; the manifest is the fallback.
499
+ const extensionCrons = this.specialtyDefaults(specialty)?.crons;
500
+ const manifest = extensionCrons
501
+ ? undefined
502
+ : this.specialtyRegistry.tryGetManifest(specialty);
503
+ if (!extensionCrons && !manifest) {
507
504
  console.error(
508
505
  `reconcileSystemCrons: no loadable manifest for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
509
506
  );
510
507
  return { created: 0, updated: 0, deleted: 0 };
511
508
  }
512
- const manifestCrons: readonly ManifestCron[] = manifest.crons;
509
+ const manifestCrons: readonly (ManifestCron | SpecialtyDefaultCron)[] =
510
+ extensionCrons ?? manifest?.crons ?? [];
513
511
 
514
512
  // Fetch all existing system crons for this agent
515
513
  const existingSystemCrons = await this.prisma.agentCronJob.findMany({
@@ -566,7 +564,7 @@ export class AgentCronJobService {
566
564
  preCheck: systemCron.preCheck ?? null,
567
565
  channel: null,
568
566
  user: null,
569
- enabled: systemCron.enabled,
567
+ enabled: systemCron.enabled ?? true,
570
568
  },
571
569
  });
572
570
  idByName.set(systemCron.name, createdCron.id);