@cat-factory/app 0.222.0 → 0.224.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.
@@ -7,6 +7,7 @@ import {
7
7
  AGENT_ARCHETYPES,
8
8
  AGENT_BY_KIND,
9
9
  setCustomAgentKindMeta,
10
+ setCustomCompanionTargets,
10
11
  SYSTEM_AGENT_META,
11
12
  uid,
12
13
  } from '~/utils/catalog'
@@ -105,6 +106,28 @@ export const useAgentsStore = defineStore('agents', () => {
105
106
  // no tick gap. The watch lives in the store's effect scope (disposed with it).
106
107
  watch(customByKind, (map) => setCustomAgentKindMeta(map), { immediate: true, flush: 'sync' })
107
108
 
109
+ /**
110
+ * The custom COMPANION pairings (companion kind → the producer kinds it reviews), read off the
111
+ * same custom-kind sources the palette is built from. Kept apart from `customByKind` because a
112
+ * pairing is not display metadata: the builder uses it to decide a kind is a TOGGLE on its
113
+ * producer rather than a placeable block, and `AgentArchetype` has no business carrying it.
114
+ */
115
+ const customCompanions = computed<Record<string, readonly AgentKind[]>>(() => {
116
+ const out: Record<string, readonly AgentKind[]> = {}
117
+ const add = (k: CustomAgentKind) => {
118
+ // A pairing with no targets is not a pairing. Recording it would make the kind vanish from
119
+ // the palette (an `isProducerCompanion` hit) with no producer to hang the toggle on.
120
+ if (k.companionTargets?.length) out[k.kind] = k.companionTargets
121
+ }
122
+ for (const k of consumerKinds.value) add(k)
123
+ for (const k of capabilitiesManifest.value?.slots?.agentKinds ?? []) add(k)
124
+ return out
125
+ })
126
+ watch(customCompanions, (map) => setCustomCompanionTargets(map), {
127
+ immediate: true,
128
+ flush: 'sync',
129
+ })
130
+
108
131
  /** Display metadata for a KNOWN kind (built-in / system / custom), else undefined. */
109
132
  function get(kind: AgentKind): AgentArchetype | undefined {
110
133
  return AGENT_BY_KIND[kind] ?? SYSTEM_AGENT_META[kind] ?? customByKind.value[kind]
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useToolServersStore } from '~/stores/toolServers'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { ToolServerProbeResult, ToolServerView } from '~/types/toolServers'
5
+
6
+ /**
7
+ * Three behaviours carry this store, each about not losing an answer:
8
+ *
9
+ * - the availability probe. A 403 ("you may not manage secrets") is an ANSWER and resolves
10
+ * normally, hiding the surface; anything else propagates, because the panel is what can tell a
11
+ * reader the list could not be fetched. There is deliberately no 503 case, unlike the
12
+ * credential store: projecting a registry needs no encryption key.
13
+ * - a probe result is kept for every VERDICT, failures included. A failure is exactly the answer
14
+ * the operator asked for, and a store that only kept successes would leave the row looking
15
+ * untouched after a dead endpoint was reported.
16
+ * - results survive a re-read of the inventory, because a result describes the SERVER rather than
17
+ * the list it arrived in.
18
+ */
19
+ function server(over: Partial<ToolServerView> = {}): ToolServerView {
20
+ return {
21
+ id: 'issues',
22
+ label: 'Issue tracker',
23
+ transport: 'http',
24
+ target: 'https://mcp.example/rpc',
25
+ declaredBy: ['coder'],
26
+ servableHarnesses: ['claude-code'],
27
+ credentials: [],
28
+ probeable: true,
29
+ ...over,
30
+ }
31
+ }
32
+
33
+ describe('toolServers store', () => {
34
+ beforeEach(() => {
35
+ useWorkspaceStore().workspaceId = 'ws1'
36
+ })
37
+
38
+ it('load stores the inventory and marks the surface available', async () => {
39
+ vi.stubGlobal('useApi', () => ({
40
+ listToolServers: () => Promise.resolve({ servers: [server()] }),
41
+ }))
42
+
43
+ const store = useToolServersStore()
44
+ await store.load()
45
+
46
+ expect(store.available).toBe(true)
47
+ expect(store.hasSurface).toBe(true)
48
+ expect(store.loading).toBe(false)
49
+ })
50
+
51
+ it('shares one read between concurrent callers', async () => {
52
+ let reads = 0
53
+ vi.stubGlobal('useApi', () => ({
54
+ listToolServers: () => {
55
+ reads++
56
+ return Promise.resolve({ servers: [server()] })
57
+ },
58
+ }))
59
+
60
+ const store = useToolServersStore()
61
+ // Both callers fire on the same interaction: the Infrastructure window asks `ensureLoaded` whether
62
+ // the tab exists at all, and the panel refreshes on mount so a redeploy shows up without a reload.
63
+ // A read that started microseconds ago IS that refresh, so it is shared rather than duplicated.
64
+ await Promise.all([store.ensureLoaded(), store.load()])
65
+
66
+ expect(reads).toBe(1)
67
+ expect(store.hasSurface).toBe(true)
68
+ })
69
+
70
+ it('a 403 latches the surface unavailable without throwing', async () => {
71
+ vi.stubGlobal('useApi', () => ({
72
+ listToolServers: () => Promise.reject({ statusCode: 403 }),
73
+ }))
74
+
75
+ const store = useToolServersStore()
76
+ await expect(store.load()).resolves.toBeUndefined()
77
+
78
+ expect(store.available).toBe(false)
79
+ expect(store.hasSurface).toBe(false)
80
+ })
81
+
82
+ it('a transient failure propagates and latches nothing', async () => {
83
+ vi.stubGlobal('useApi', () => ({
84
+ listToolServers: () => Promise.reject({ statusCode: 500 }),
85
+ }))
86
+
87
+ const store = useToolServersStore()
88
+ await expect(store.load()).rejects.toBeDefined()
89
+
90
+ // `available` stays null so `ensureLoaded` remains retryable, and an already-loaded surface is
91
+ // not hidden by one bad read.
92
+ expect(store.available).toBeNull()
93
+ })
94
+
95
+ it('reports no surface for a deployment that registers no tool server', async () => {
96
+ vi.stubGlobal('useApi', () => ({ listToolServers: () => Promise.resolve({ servers: [] }) }))
97
+
98
+ const store = useToolServersStore()
99
+ await store.load()
100
+
101
+ // An answer rather than possibly an outage: the inventory is read off this process's own
102
+ // registry, which is why there is no `declarationsIncomplete` counterpart here.
103
+ expect(store.available).toBe(true)
104
+ expect(store.hasSurface).toBe(false)
105
+ })
106
+
107
+ it('keeps a FAILING probe result, and keeps it across a re-read of the inventory', async () => {
108
+ const failure: ToolServerProbeResult = {
109
+ serverId: 'issues',
110
+ status: 'unreachable',
111
+ error: 'TypeError: fetch failed',
112
+ }
113
+ vi.stubGlobal('useApi', () => ({
114
+ listToolServers: () => Promise.resolve({ servers: [server()] }),
115
+ probeToolServer: () => Promise.resolve(failure),
116
+ }))
117
+
118
+ const store = useToolServersStore()
119
+ await store.load()
120
+ await store.probe('issues')
121
+
122
+ expect(store.results.issues).toEqual(failure)
123
+ expect(store.probing).toBeNull()
124
+
125
+ await store.load()
126
+ expect(store.results.issues).toEqual(failure)
127
+ })
128
+
129
+ it('propagates a THROWN probe failure without storing a verdict', async () => {
130
+ // A 404 for a server the deployment has since dropped, or a transient 5xx: not a verdict, so the
131
+ // row must not claim the probe answered.
132
+ vi.stubGlobal('useApi', () => ({
133
+ probeToolServer: () => Promise.reject({ statusCode: 404 }),
134
+ }))
135
+
136
+ const store = useToolServersStore()
137
+ await expect(store.probe('ghost')).rejects.toBeDefined()
138
+
139
+ expect(store.results.ghost).toBeUndefined()
140
+ expect(store.probing).toBeNull()
141
+ })
142
+ })
@@ -0,0 +1,112 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { ToolServerProbeResult, ToolServersView } from '~/types/toolServers'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { apiErrorStatus } from '~/composables/api/errors'
6
+
7
+ /**
8
+ * The deployment's tool servers (MCP) and the results of probing them.
9
+ *
10
+ * Two halves with different lifetimes, which is why they are separate refs. The INVENTORY is
11
+ * deployment code: it changes when the deployment redeploys, so it is loaded on demand and re-read
12
+ * rather than patched. A PROBE RESULT is a moment in time and belongs to the operator who asked for
13
+ * it, so results are kept per server id and never fetched eagerly — a probe spends an outbound
14
+ * request under the deployment's own credential, so opening a panel must not fire one.
15
+ *
16
+ * Mirrors the capability-credential store's availability handling deliberately: the two surfaces sit
17
+ * in one tab, gate on the same permission, and a member without it must see neither.
18
+ */
19
+ export const useToolServersStore = defineStore('toolServers', () => {
20
+ const api = useApi()
21
+
22
+ const view = ref<ToolServersView | null>(null)
23
+ // Probe results by server id. Kept after a re-read of the inventory: a result describes the server
24
+ // rather than the list it arrived in, and dropping it on refresh would erase the answer the
25
+ // operator just asked for.
26
+ const results = ref<Record<string, ToolServerProbeResult>>({})
27
+ const probing = ref<string | null>(null)
28
+ const loading = ref(false)
29
+ // The backend's two definitive refusals: no `secrets.manage` (403), and — unlike the credential
30
+ // store — never a 503, since the inventory needs no encryption key to project a registry. `null`
31
+ // until first probed. A 403 HIDES the surface rather than disabling it, because the inventory
32
+ // names the deployment's credential keys and its endpoints.
33
+ const available = ref<boolean | null>(null)
34
+ let inFlight: Promise<void> | null = null
35
+
36
+ /**
37
+ * Whether there is anything to show. A deployment that registers no tool server has no row to
38
+ * render, and the panel section is hidden rather than rendering an empty heading.
39
+ *
40
+ * No `declarationsIncomplete` counterpart here, unlike the credential checklist: the inventory is
41
+ * read straight off this process's own registry, so an empty answer is an answer rather than
42
+ * possibly an outage.
43
+ */
44
+ const hasSurface = computed(() => (view.value?.servers.length ?? 0) > 0)
45
+
46
+ /**
47
+ * Refresh the inventory, sharing a read that is already in flight.
48
+ *
49
+ * Coalescing belongs on `load` and not only on `ensureLoaded` because both callers fire on the
50
+ * same interaction: the Infrastructure window calls `ensureLoaded` to decide whether the tab
51
+ * exists at all, and the panel refreshes on mount so a redeploy shows up without a reload. Two
52
+ * identical GETs per open is what a plain "force" would have cost, and a read that started
53
+ * microseconds ago IS the refresh.
54
+ */
55
+ async function load() {
56
+ if (inFlight) return inFlight
57
+ inFlight = readInventory().finally(() => (inFlight = null))
58
+ return inFlight
59
+ }
60
+
61
+ async function readInventory() {
62
+ const ws = useWorkspaceStore()
63
+ loading.value = true
64
+ try {
65
+ view.value = await api.listToolServers(ws.requireId())
66
+ available.value = true
67
+ } catch (err) {
68
+ if (apiErrorStatus(err) === 403) {
69
+ // A definitive answer, not a failure: this caller may not manage secrets. Hide the surface
70
+ // and stop probing; resolve normally.
71
+ available.value = false
72
+ view.value = null
73
+ return
74
+ }
75
+ // Any other failure (transient 5xx / network) leaves the state untouched, so it neither hides
76
+ // an available panel nor caches a false "unavailable", and PROPAGATES: the panel is the one
77
+ // surface that can tell a reader it is looking at a list we could not fetch. Same split as the
78
+ // capability-credential store.
79
+ throw err
80
+ } finally {
81
+ loading.value = false
82
+ }
83
+ }
84
+
85
+ /** Load once and stay loaded; `load()` re-reads (both share whatever is in flight). */
86
+ async function ensureLoaded() {
87
+ if (available.value !== null) return
88
+ return load()
89
+ }
90
+
91
+ /**
92
+ * Probe ONE server and keep its result.
93
+ *
94
+ * The result is stored for every outcome, failures included: a failure IS the answer the operator
95
+ * asked for, and a store that only kept successes would leave the row looking untouched after the
96
+ * probe reported a dead endpoint. A thrown error (a 404 for a server the deployment has since
97
+ * dropped, a transient 5xx) propagates instead, because those are not probe verdicts.
98
+ */
99
+ async function probe(id: string) {
100
+ const ws = useWorkspaceStore()
101
+ probing.value = id
102
+ try {
103
+ const result = await api.probeToolServer(ws.requireId(), id)
104
+ results.value = { ...results.value, [id]: result }
105
+ return result
106
+ } finally {
107
+ probing.value = null
108
+ }
109
+ }
110
+
111
+ return { view, results, probing, loading, available, hasSurface, load, ensureLoaded, probe }
112
+ })
@@ -0,0 +1,17 @@
1
+ // Tool server (MCP) operability shapes: what this deployment declared, and what a probe answered.
2
+ //
3
+ // All wire shapes are sourced from @cat-factory/contracts (single source of truth). The probe
4
+ // STATUS and the not-probeable REASON in particular are vocabularies both sides must agree about —
5
+ // the backend decides them, this app maps each member to translated copy plus a remedy — so a
6
+ // member added on one side only renders as a blank chip rather than failing to compile.
7
+
8
+ export type {
9
+ ToolServerAllowedToolsCheck,
10
+ ToolServerCredential,
11
+ ToolServerNotProbeableReason,
12
+ ToolServerProbeResult,
13
+ ToolServerProbeStatus,
14
+ ToolServerTransport,
15
+ ToolServerView,
16
+ ToolServersView,
17
+ } from '@cat-factory/contracts'
@@ -0,0 +1,80 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import type { AgentKind } from '~/types/domain'
3
+ import {
4
+ COMPANION_FOR_PRODUCER,
5
+ __resetCustomCompanionTargetsForTest,
6
+ companionForProducer,
7
+ isProducerCompanion,
8
+ setCustomCompanionTargets,
9
+ } from '~/utils/catalog'
10
+
11
+ // The SPA half of the companion registry. A companion is not a placeable palette block: the
12
+ // builder renders it as an "add companion" toggle ON its producer and inserts it immediately
13
+ // after. These two lookups are what decide that, so a deployment's registered pair is either
14
+ // visible as a toggle or invisible entirely, with nothing in between.
15
+ //
16
+ // The backend half is `extension-registries.companions.test.ts`. Both are needed: the backend
17
+ // enforces the adjacency rule on save, and this decides whether a person can ever express the
18
+ // pairing in the first place.
19
+
20
+ afterEach(() => {
21
+ __resetCustomCompanionTargetsForTest()
22
+ })
23
+
24
+ describe('custom companion pairings in the palette', () => {
25
+ it('has no opinion about a deployment’s pair until the store projects it', () => {
26
+ // The pre-registration state is a real one: the snapshot arrives after first paint, and a
27
+ // custom companion must degrade to an ordinary kind rather than to a broken toggle.
28
+ expect(isProducerCompanion('acme:migration-auditor')).toBe(false)
29
+ expect(companionForProducer('acme:migrator')).toBeUndefined()
30
+ })
31
+
32
+ it('renders a registered pair as a toggle on its producer', () => {
33
+ setCustomCompanionTargets({ 'acme:migration-auditor': ['acme:migrator'] })
34
+ expect(companionForProducer('acme:migrator')).toBe('acme:migration-auditor')
35
+ // ...and the companion itself leaves the palette, which is the other half of "it is a
36
+ // toggle, not a block". Registering only one of these would show the kind twice.
37
+ expect(isProducerCompanion('acme:migration-auditor')).toBe(true)
38
+ // The producer is not a companion just for being reviewed by one.
39
+ expect(isProducerCompanion('acme:migrator')).toBe(false)
40
+ })
41
+
42
+ it('lets one companion review several producers', () => {
43
+ setCustomCompanionTargets({ 'acme:auditor': ['acme:migrator', 'acme:packager'] })
44
+ expect(companionForProducer('acme:migrator')).toBe('acme:auditor')
45
+ expect(companionForProducer('acme:packager')).toBe('acme:auditor')
46
+ })
47
+
48
+ it('never lets a deployment re-point a BUILT-IN producer at its own companion', () => {
49
+ // Built-ins win, matching `agentKindMeta`'s precedence and the backend registry's refusal to
50
+ // shadow a built-in kind. The shipped pairing is what every stock pipeline relies on, so a
51
+ // silent re-point would change what those pipelines do without anyone editing them.
52
+ setCustomCompanionTargets({ 'acme:reviewer': ['coder'] })
53
+ expect(companionForProducer('coder')).toBe(COMPANION_FOR_PRODUCER.coder)
54
+ // The custom kind still leaves the palette: it IS a companion, it just does not get `coder`.
55
+ expect(isProducerCompanion('acme:reviewer')).toBe(true)
56
+ })
57
+
58
+ it('resolves a contested producer deterministically, first registration winning', () => {
59
+ // Only one toggle can hang off a producer, so two companions claiming it is a case with an
60
+ // answer whether or not anyone chose one. Pinning it here is what keeps the answer from
61
+ // being "whichever the object happened to enumerate first".
62
+ const contested: Record<string, readonly AgentKind[]> = {
63
+ 'acme:first': ['acme:migrator'],
64
+ 'acme:second': ['acme:migrator'],
65
+ }
66
+ setCustomCompanionTargets(contested)
67
+ expect(companionForProducer('acme:migrator')).toBe('acme:first')
68
+ expect(isProducerCompanion('acme:second')).toBe(true)
69
+ })
70
+
71
+ it('drops a pairing when the catalog it came from goes away', () => {
72
+ setCustomCompanionTargets({ 'acme:migration-auditor': ['acme:migrator'] })
73
+ expect(companionForProducer('acme:migrator')).toBe('acme:migration-auditor')
74
+ // A workspace switch re-projects an empty catalog. The lookups must follow it rather than
75
+ // keep answering from the old deployment's registrations.
76
+ setCustomCompanionTargets({})
77
+ expect(companionForProducer('acme:migrator')).toBeUndefined()
78
+ expect(isProducerCompanion('acme:migration-auditor')).toBe(false)
79
+ })
80
+ })
@@ -37,6 +37,7 @@ const AGENT_KINDS: AgentKind[] = [
37
37
  'business-reviewer',
38
38
  'human-test',
39
39
  'visual-confirmation',
40
+ 'disposer',
40
41
  ]
41
42
  const BLOCK_TYPES: BlockType[] = [
42
43
  'frontend',
@@ -1,4 +1,4 @@
1
- import { shallowRef } from 'vue'
1
+ import { computed, shallowRef } from 'vue'
2
2
  import type {
3
3
  AgentArchetype,
4
4
  AgentCategory,
@@ -228,6 +228,21 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
228
228
  // recreate / destroy) instead of the generic prose step-detail panel.
229
229
  resultView: 'human-test',
230
230
  },
231
+ {
232
+ // The `deployer`'s counterpart at the other end of the environment lifecycle, and a PALETTE
233
+ // block rather than a system kind precisely because deciding WHEN the environment goes away
234
+ // is the point of it: after the automated tester, or after a human has finished with the live
235
+ // URL. Without one, the TTL sweep reclaims environments on a timer long after the run
236
+ // settled, which is a fine backstop and cannot close the run's own teardown proof.
237
+ kind: 'disposer',
238
+ tier: 'intermediate',
239
+ label: 'Disposer',
240
+ icon: 'i-lucide-cloud-off',
241
+ color: '#34d399',
242
+ category: 'test',
243
+ description:
244
+ 'Reclaims the ephemeral environments this run provisioned, and confirms they are actually gone. Place it after the last step that needs the environment.',
245
+ },
231
246
  {
232
247
  kind: 'visual-confirmation',
233
248
  tier: 'advanced',
@@ -322,9 +337,13 @@ export const COMPANION_ARCHETYPES: AgentArchetype[] = [
322
337
  ]
323
338
 
324
339
  /**
325
- * Producer agent kind → its companion agent kind. Mirrors the backend `COMPANIONS` registry
326
- * (`@cat-factory/agents`). The builder shows an "add companion" toggle on a producer step
327
- * found here, and inserts/removes the companion immediately after it.
340
+ * Producer agent kind → its companion agent kind, for the BUILT-IN pairs. Mirrors the backend
341
+ * `COMPANIONS` catalog (`@cat-factory/agents`). The builder shows an "add companion" toggle on
342
+ * a producer step found here, and inserts/removes the companion immediately after it.
343
+ *
344
+ * A DEPLOYMENT's own pair does not live here: it arrives on the snapshot as a custom agent
345
+ * kind carrying `companionTargets`, and is projected into {@link customCompanionTargets} by the
346
+ * agents store. Both are consulted below, built-ins first.
328
347
  */
329
348
  export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
330
349
  coder: 'reviewer',
@@ -335,18 +354,67 @@ export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
335
354
 
336
355
  const COMPANION_KINDS: ReadonlySet<string> = new Set(COMPANION_ARCHETYPES.map((a) => a.kind))
337
356
 
338
- /** The companion kind that depends on a producer kind, or undefined if it has none. */
357
+ /**
358
+ * Reactive read-model of the deployment's CUSTOM companion pairings (companion kind → the
359
+ * producer kinds it reviews), kept in sync by the agents store from the snapshot's
360
+ * `customAgentKinds[].companionTargets`.
361
+ *
362
+ * A `shallowRef` for the same reason {@link customAgentKindMeta} is one: the pure lookups below
363
+ * must resolve a registered companion, and re-render when the catalog changes, without importing
364
+ * the store (circular) or mutating the frozen built-in map. Empty until the store first
365
+ * populates it, so a custom companion degrades to "not a companion" (an ordinary palette block),
366
+ * exactly as before registration.
367
+ */
368
+ const customCompanionTargets = shallowRef<Record<string, readonly AgentKind[]>>({})
369
+
370
+ /**
371
+ * The same projection INVERTED: producer kind → the companion that reviews it, the direction
372
+ * {@link companionForProducer} actually asks in. Derived rather than scanned per call, because
373
+ * that lookup runs once per step of every pipeline the builder renders.
374
+ *
375
+ * Inverting is also where an ambiguity has to be RESOLVED rather than left to iteration order:
376
+ * two registered companions may both claim a producer, and only one toggle can hang off it.
377
+ * First registration wins, stated here once, instead of "whichever `Object.entries` reached
378
+ * first" being the answer at each call site.
379
+ */
380
+ const customCompanionByProducer = computed<Record<string, AgentKind>>(() => {
381
+ const out: Record<string, AgentKind> = {}
382
+ for (const [companion, targets] of Object.entries(customCompanionTargets.value)) {
383
+ for (const producer of targets) if (!(producer in out)) out[producer] = companion
384
+ }
385
+ return out
386
+ })
387
+
388
+ /** Replace the custom companion projection (called only by the agents store). */
389
+ export function setCustomCompanionTargets(map: Record<string, readonly AgentKind[]>): void {
390
+ customCompanionTargets.value = map
391
+ }
392
+
393
+ /** Test-only: clear the custom companion projection so a spec starts from built-ins only. */
394
+ export function __resetCustomCompanionTargetsForTest(): void {
395
+ customCompanionTargets.value = {}
396
+ }
397
+
398
+ /**
399
+ * The companion kind that depends on a producer kind, or undefined if it has none.
400
+ *
401
+ * Built-ins win. A deployment cannot re-point `coder` at its own reviewer by registering one,
402
+ * for the same reason `agentKindMeta`'s precedence puts built-ins first and the backend registry
403
+ * never shadows a built-in kind: the shipped pairing is the one the engine's own pipelines rely
404
+ * on, and a silent re-point would change what every stock pipeline does.
405
+ */
339
406
  export function companionForProducer(kind: string): AgentKind | undefined {
340
- return COMPANION_FOR_PRODUCER[kind]
407
+ return COMPANION_FOR_PRODUCER[kind] ?? customCompanionByProducer.value[kind]
341
408
  }
342
409
 
343
410
  /**
344
411
  * Whether a kind is a dependent producer-companion (reviewer / architect-companion /
345
- * spec-companion) rendered as a toggle on its producer, not a standalone palette block.
346
- * Distinct from `pipelineRender`'s `isCompanionKind`, which also counts the Tester's `fixer`.
412
+ * spec-companion, or a deployment's own): rendered as a toggle on its producer, not a
413
+ * standalone palette block. Distinct from `pipelineRender`'s `isCompanionKind`, which also
414
+ * counts the Tester's `fixer`.
347
415
  */
348
416
  export function isProducerCompanion(kind: string): boolean {
349
- return COMPANION_KINDS.has(kind)
417
+ return COMPANION_KINDS.has(kind) || kind in customCompanionTargets.value
350
418
  }
351
419
 
352
420
  /**
@@ -606,6 +606,48 @@
606
606
  "removeFailed": "Der Registry-Eintrag konnte nicht entfernt werden"
607
607
  }
608
608
  },
609
+ "toolServers": {
610
+ "heading": "Werkzeugserver (MCP)",
611
+ "intro": "Die MCP-Server, die diese Installation für ihre Agenten registriert. Ein Test löst die Zugangsdaten dieses Boards auf und spricht das Protokoll mit dem Server, das Ergebnis entspricht also dem, was ein Lauf erhält.",
612
+ "transport": {
613
+ "stdio": "Im Container",
614
+ "http": "Extern"
615
+ },
616
+ "declaredBy": "Zugewiesen an: {kinds}",
617
+ "declaredByNone": "Kein Agent erhält diesen Server, daher startet ihn kein Lauf.",
618
+ "servableHarnesses": "Läuft mit: {harnesses}",
619
+ "servableHarnessesNone": "Keine Agenten-CLI kann diesen Transport bedienen, daher greift dieser Server in keinem Lauf.",
620
+ "allowedTools": "Eingeschränkt auf: {tools}",
621
+ "credentials": "Zugangsdaten: {keys}",
622
+ "test": "Testen",
623
+ "notProbeable": {
624
+ "stdio": "Läuft im Container des Agenten und kann von hier aus nicht getestet werden.",
625
+ "containerLocal": "Lauscht neben dem Agenten in dessen eigenem Container, der von hier aus nicht erreichbar ist.",
626
+ "urlNotAllowed": "Unter dieser Adresse darf ein Werkzeugserver nicht erreicht werden (https oder einfaches http nur auf localhost)."
627
+ },
628
+ "status": {
629
+ "ok": "Hat geantwortet",
630
+ "credentialsMissing": "Keine Zugangsdaten",
631
+ "credentialRefused": "Zugangsdaten abgelehnt",
632
+ "unreachable": "Keine Antwort",
633
+ "httpError": "Anfrage abgewiesen",
634
+ "protocolError": "Kein MCP-Server",
635
+ "notProbeable": "Von hier nicht testbar"
636
+ },
637
+ "okDetail": "{name} {version}, Protokoll {protocol}, {count} Werkzeuge.",
638
+ "toolsIncomplete": "Der Server hat mehr Werkzeuge, als ein Test liest, diese Zahl ist also ein Mindestwert.",
639
+ "unmatchedTools": "Dieser Server bietet kein Werkzeug namens {tools}, dem Agenten wird also ein Werkzeug angekündigt, das er nicht aufrufen kann.",
640
+ "allowedToolsUnchecked": "Die Werkzeugliste war zu lang, um sie vollständig zu lesen, daher konnten die eingeschränkten Namen nicht geprüft werden.",
641
+ "unresolvedCredentials": "Für {keys} wurde nichts aufgelöst. Trage den Wert unten ein oder setze ihn in der Umgebung der Installation.",
642
+ "refusedCredentials": "{keys} benennt eine Variable, die zur Konfiguration der Plattform selbst gehört, und wird daher nie aufgelöst. Ändere die Deklaration im Code der Installation.",
643
+ "httpStatus": "HTTP {status}",
644
+ "showDetails": "Details anzeigen",
645
+ "hideDetails": "Details verbergen",
646
+ "toast": {
647
+ "loadFailed": "Die Werkzeugserver konnten nicht geladen werden",
648
+ "probeFailed": "Der Werkzeugserver konnte nicht getestet werden"
649
+ }
650
+ },
609
651
  "capabilityCredentials": {
610
652
  "tab": "Zugangsdaten für Fähigkeiten",
611
653
  "intro": "Die Secrets, die die Tool-Server und generativen Integrationen dieser Installation namentlich anfordern. Werte gelten nur für dieses Board, werden verschlüsselt gespeichert und direkt an den Prozess des Agenten übergeben: Sie erscheinen weder in einem Prompt noch in einem Log. Werte lassen sich nur schreiben, nie auslesen, ein gespeicherter Wert wird also durch Eingabe eines neuen ersetzt.",
@@ -1491,6 +1533,12 @@
1491
1533
  "inherited": "vom Service geerbt",
1492
1534
  "frozen": "Eingefroren: der Agent hat gestartet"
1493
1535
  },
1536
+ "taskTypeFields": {
1537
+ "title": "Aufgabentyp-Felder",
1538
+ "hint": "Die Antworten, die dieser Aufgabentyp benötigt. Fehlt eine Pflichtangabe, wird ein Lauf vor dem Start angehalten.",
1539
+ "save": "Speichern",
1540
+ "revert": "Zurücksetzen"
1541
+ },
1494
1542
  "dependencies": {
1495
1543
  "title": "Hängt ab von",
1496
1544
  "hint": "Aufgaben, die gemergt sein müssen, bevor diese laufen kann. Das Run-Steuerelement bleibt gesperrt, bis jede Abhängigkeit erledigt ist.",
@@ -5519,6 +5567,11 @@
5519
5567
  "title": "Keine Erfolgskriterien",
5520
5568
  "hint": "Nenne die Frage, die der Spike beantwortet, oder wie ein gutes Ergebnis aussieht, damit die Zeitbox ein Ziel hat."
5521
5569
  },
5570
+ "required_field_missing": {
5571
+ "title": "Fehlt: {field}",
5572
+ "hint": "Dieser Aufgabentyp verlangt „{field}“, die Aufgabe beantwortet es aber nicht. Trage es an der Aufgabe nach und prüfe erneut.",
5573
+ "unnamedField": "ein Pflichtfeld"
5574
+ },
5522
5575
  "unknown": {
5523
5576
  "title": "Unbekannter Befund",
5524
5577
  "hint": "Dieser Lauf hat eine Prüfung erfasst, die diese Version nicht mehr kennt. Sieh die Aufgabe von Hand durch, bevor du fortfährst."
@@ -5872,6 +5925,7 @@
5872
5925
  "operation": {
5873
5926
  "provision": "Hochfahren",
5874
5927
  "teardown": "Abbauen",
5928
+ "teardown-verify": "Abbau-Prüfung",
5875
5929
  "status": "Statusprüfung",
5876
5930
  "dispatch": "Hochfahren",
5877
5931
  "release": "Abbauen",