dsh-ptc-plus 0.5.3 → 0.5.4

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 (40) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/client.cjs +3326 -2881
  4. package/compiler-core.cjs +127 -127
  5. package/compiler-core.cjs.map +2 -2
  6. package/docs/README.md +1 -0
  7. package/docs/capability-planning-components.md +6 -6
  8. package/docs/capability-projection.md +1 -1
  9. package/docs/capability-surface.md +133 -0
  10. package/docs/client-ui.md +17 -15
  11. package/docs/client-visual-contract.md +7 -7
  12. package/docs/installation.md +9 -2
  13. package/docs/panel-concept.md +171 -0
  14. package/docs/releases/v0.5.4.md +75 -0
  15. package/docs/runtime-reference.md +14 -0
  16. package/docs/verification.md +1 -1
  17. package/index.js +117 -30
  18. package/internal/binding-adoption.js +103 -11
  19. package/internal/capability-command-receipt.js +99 -15
  20. package/internal/capability-environment.js +1108 -348
  21. package/internal/capability-plan.js +18 -2
  22. package/internal/capability-policy.js +13 -0
  23. package/internal/capability-selection.js +330 -0
  24. package/internal/direct-surface-owner.js +17 -4
  25. package/internal/message-sources.js +21 -0
  26. package/internal/program-bindings.js +13 -4
  27. package/internal/runtime-bridge-owner.js +5 -1
  28. package/internal/runtime-config.js +10 -0
  29. package/internal/session-log-view.js +47 -5
  30. package/internal/settings-compat.js +6 -0
  31. package/internal/user-bindings-owner.js +20 -3
  32. package/package.json +45 -31
  33. package/src/client-authoring-view.js +7 -8
  34. package/src/client-capabilities-view.js +871 -948
  35. package/src/client-capability-sort.js +22 -9
  36. package/src/client-copy.js +116 -294
  37. package/src/client-settings-view.js +90 -33
  38. package/src/client-styles.js +67 -19
  39. package/src/client.js +46 -21
  40. package/internal/capability-delivery-proof.js +0 -70
@@ -1,36 +1,62 @@
1
- import { capabilityPolicyForConfig, capabilityRule, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from '../internal/capability-policy.js'
1
+ import { capabilityPolicyForConfig, capabilityPolicyFromRecord, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from '../internal/capability-policy.js'
2
2
  import { CONFIG_DEFAULTS, CONFIG_FIELDS } from '../internal/config-spec.js'
3
3
  import { CAPABILITY_CONFIGURATION_KEYS, capabilityProfileConfiguration, validateCapabilityProfiles } from '../internal/runtime-config.js'
4
4
  import { readCapabilityCommandReceipt } from '../internal/capability-command-receipt.js'
5
+ import { capabilitySortDirection, cycleCapabilitySort, sortCapabilityEntries } from './client-capability-sort.js'
5
6
  import { createFallbackModal } from './client-primitives.js'
6
- import { CAPABILITY_SORT_COLUMNS, capabilitySortDirection, cycleCapabilitySort, sortCapabilityEntries } from './client-capability-sort.js'
7
7
  import {
8
8
  PROMPT_ASSETS, PROMPT_TEXT_MAX_SLOT_BYTES, PROMPT_TEXT_SCHEMA,
9
9
  accountPromptText, parsePromptTextDocument, resolvePromptText,
10
10
  } from '../internal/prompt-text.js'
11
11
 
12
12
  /**
13
- * One capability, three separate facts, in the reader's language. Saved,
14
- * delivered and usable are different questions, so a reader must be able to see
15
- * which one failed instead of reading one blanket "applied".
13
+ * The command's own outcome, in the reader's language.
14
+ *
15
+ * A receipt may report that it found no active PTC session to update. That is a
16
+ * lookup result and a statement about what the command did not do, so the notice
17
+ * says so; it is not a usability verdict about the session and no state is named
18
+ * after one. Releases up to 0.5.4 recorded that lookup as the wire state
19
+ * `unavailable`, so the mapping below keeps those recorded notices readable; the
20
+ * chat notice is the only reader that can still meet one.
16
21
  */
17
- export function capabilityProofCopy(t, proof) {
18
- const delivery = ['delivered', 'pending', 'absent'].includes(proof?.delivery) ? proof.delivery : undefined
19
- const usability = ['proven', 'unproven'].includes(proof?.usability) ? proof.usability : undefined
20
- if (delivery === undefined || usability === undefined) return t('capabilities.quick.unknown')
21
- return t('capabilities.proof.line', {
22
- saved: t(proof.saved === true ? 'capabilities.proof.saved.yes' : 'capabilities.proof.saved.no'),
23
- delivery: t(`capabilities.proof.delivery.${delivery}`),
24
- usability: t(`capabilities.proof.usability.${usability}`),
25
- })
22
+ const RECEIPT_STATE = Object.freeze({
23
+ recorded: 'recorded', pending: 'pending', 'saved-only': 'saved-only',
24
+ paused: 'paused', off: 'off', usage: 'usage', unavailable: 'no-session',
25
+ })
26
+ const receiptCopyKey = (surface, state) => `capabilities.${surface}.${RECEIPT_STATE[state] ?? 'usage'}`
27
+
28
+ /** The record one write named, in the §4 vocabulary the panel's columns use. */
29
+ function capabilityRecordText(t, record) {
30
+ if (record === null || typeof record !== 'object' || typeof record.id !== 'string') return undefined
31
+ return `${record.id} · ${record.dimension === undefined ? '' : `${t(`capabilities.dimension.${record.dimension}`)} `}`
32
+ + t(`capabilities.word.${record.selection}`)
33
+ }
34
+
35
+ /**
36
+ * One receipt, one sentence, in the reader's language.
37
+ *
38
+ * A receipt states the write this command performed: which entry, which choice,
39
+ * and which session or global configuration now records it. Whether a cell later
40
+ * reached that entry is a catalogue row's `evidence` field — a marker means an
41
+ * accepted execution obtained it, and this page states no such column — so no
42
+ * receipt states it. The chat card and the composer
43
+ * toast read this one function; the `surface` argument picks which outcome
44
+ * sentence describes the command, and a write that named a record reads the same
45
+ * on both.
46
+ */
47
+ export function capabilityReceiptNotice(t, receipt, surface = 'receipt') {
48
+ const record = capabilityRecordText(t, receipt?.record)
49
+ if (record !== undefined && receipt.state === 'recorded') {
50
+ return t(`capabilities.receipt.record${receipt.record.scope === 'global' ? '.global' : ''}`,
51
+ { session: receipt.sessionId, record })
52
+ }
53
+ return t(receiptCopyKey(surface, receipt?.state), { session: receipt?.sessionId, error: receipt?.error ?? '' })
26
54
  }
27
55
 
28
56
  export function capabilityCommandText(t, text) {
29
57
  if (text?.key) return t(text.key, text.params)
30
58
  const receipt = readCapabilityCommandReceipt(text)
31
- if (!receipt) return text
32
- const line = t(`capabilities.receipt.${receipt.state}`, { session: receipt.sessionId, error: receipt.error ?? '' })
33
- return receipt.proof === undefined ? line : `${line} ${capabilityProofCopy(t, receipt.proof)}`
59
+ return receipt === undefined ? text : capabilityReceiptNotice(t, receipt, 'receipt')
34
60
  }
35
61
 
36
62
  // Host-owned context the model sees in PTC mode. The plugin never receives the host's
@@ -50,13 +76,13 @@ const PROMPT_TABLE_COLUMNS = PROMPT_TABLE_COLUMN_KEYS.length
50
76
  function capabilityChoiceLabel(t, value) {
51
77
  return t(value === 'inherit' ? 'capabilities.quick.noOverride'
52
78
  : ['enabled', 'pending', 'unknown'].includes(value) ? `capabilities.quick.${value}`
53
- : [...selections, ...CAPABILITY_DISCLOSURES].includes(value) ? `capabilities.${value}` : 'capabilities.quick.unknown')
79
+ : [...selections, ...CAPABILITY_DISCLOSURES].includes(value) ? `capabilities.word.${value}` : 'capabilities.quick.unknown')
54
80
  }
55
81
 
56
- function capabilityRuleLabel(t, rule, empty = 'capabilities.policy.inheritRule') {
82
+ function capabilityRuleLabel(t, rule, empty = 'capabilities.policy.layerAbove') {
57
83
  const dimensions = ['availability', 'disclosure'].filter(dimension => rule?.[dimension] !== undefined)
58
84
  return dimensions.length ? dimensions.map(dimension => `${t(`capabilities.policy.${dimension}`)}: ${rule[dimension] === 'automatic'
59
- ? t('capabilities.policy.inheritRule') : capabilityChoiceLabel(t, rule[dimension])}`).join(' · ') : t(empty)
85
+ ? t('capabilities.policy.layerAbove') : capabilityChoiceLabel(t, rule[dimension])}`).join(' · ') : t(empty)
60
86
  }
61
87
 
62
88
  function validCatalogRule(rule, temporary = false) {
@@ -66,6 +92,100 @@ function validCatalogRule(rule, temporary = false) {
66
92
  ? ['enabled', 'closed'].includes(choice) : CAPABILITY_DISCLOSURES.includes(choice))))
67
93
  }
68
94
 
95
+ const CATALOG_DIMENSIONS = ['availability', 'disclosure']
96
+ // The panel's own word table, column by column: a dimension keeps the word naming
97
+ // where its value comes from, so the saved column can say `automatic` and the
98
+ // session column can also say `inherit`, and neither stands in for a decision.
99
+ const CATALOG_STATEMENT_WORDS = Object.freeze({
100
+ saved: Object.freeze({ availability: ['enabled', 'closed', 'automatic'],
101
+ disclosure: [...CAPABILITY_DISCLOSURES, 'automatic'] }),
102
+ temporary: Object.freeze({ availability: ['enabled', 'closed', 'automatic', 'inherit'],
103
+ disclosure: [...CAPABILITY_DISCLOSURES, 'automatic', 'inherit'] }),
104
+ })
105
+
106
+ /**
107
+ * One column statement as the listing publishes it: the panel's value for the
108
+ * dimensions that row decides.
109
+ *
110
+ * A capability row decides both dimensions and an MCP server row only
111
+ * availability, so a statement is a dimension object with one or two entries.
112
+ * Releases up to 0.5.4 stated one whole-selection word per column; a string stays
113
+ * readable as that word, and the earlier independent-rule form stays readable as
114
+ * the rule it was.
115
+ */
116
+ function validCatalogStatement(column, value) {
117
+ if (typeof value === 'string') return true
118
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
119
+ const dimensions = Object.entries(value)
120
+ // A dimension name resolves to this column's own word list or to nothing at all: an own
121
+ // key such as `constructor` would otherwise reach an inherited function and throw while
122
+ // the card is rendering, where every other out-of-table shape falls back to the source.
123
+ return dimensions.length > 0 && dimensions.every(([dimension, choice]) =>
124
+ Object.hasOwn(CATALOG_STATEMENT_WORDS[column], dimension)
125
+ && CATALOG_STATEMENT_WORDS[column][dimension].includes(choice))
126
+ }
127
+
128
+ /** The panel's words for one statement, in the panel's column order. */
129
+ function catalogStatementLabel(t, statement) {
130
+ return CATALOG_DIMENSIONS.filter(dimension => statement[dimension] !== undefined)
131
+ .map(dimension => t(`capabilities.word.${statement[dimension]}`)).join(' · ')
132
+ }
133
+
134
+ // A Skill's declaring bucket is one of the public provider sources. A bucket this
135
+ // build does not name is stated beside the generic Skill source rather than denied.
136
+ const SKILL_SOURCE_BUCKETS = Object.freeze(['project-dsh', 'project-agents', 'user-dsh', 'user-agents',
137
+ 'runtime', 'bundled', 'custom'])
138
+
139
+ /** The declared MCP servers whose `mcp__<server>__` namespace begins one tool name. */
140
+ const mcpServersOf = source => Array.isArray(source?.servers)
141
+ ? source.servers.filter(server => typeof server === 'string')
142
+ : typeof source?.server === 'string' ? [source.server] : []
143
+
144
+ /**
145
+ * The source a row states when its payload names none.
146
+ *
147
+ * A settings row can name an identity that only the saved configuration or a frozen
148
+ * plan mentions. The identity still names its namespace — `tools.*` is the Host tool
149
+ * registry's, `skills.*` is a Skill's, `mcp.*`/`mcpServers.*` is MCP's — and stating
150
+ * the namespace is the same fact `inventory` states when no declared server names a
151
+ * tool.
152
+ */
153
+ const namespaceSource = entry => {
154
+ const namespace = typeof entry.id === 'string' ? entry.id.slice(0, entry.id.indexOf('.')) : ''
155
+ return { kind: namespace === 'skills' ? 'skill'
156
+ : namespace === 'mcp' || namespace === 'mcpServers' ? 'mcp' : 'host' }
157
+ }
158
+
159
+ /**
160
+ * The source column's statement for one declared row.
161
+ *
162
+ * `servers` is every declared MCP server whose namespace names the row. One name can
163
+ * begin with two (`mcp__docs__deep__search` is named by `docs` and by `docs__deep`),
164
+ * and then the column states both of them: a source column that cannot name its
165
+ * source has nothing left to say but a denial, which §4 forbids. Payloads written
166
+ * before that list travelled said `kind: 'unknown'`; they still state that several
167
+ * servers match. Every other row states the source its payload names, or the
168
+ * namespace its identity belongs to.
169
+ */
170
+ function capabilitySourceText(t, entry) {
171
+ // A server row is the declared server layer itself, so its source is MCP: naming
172
+ // the server there would state the row's own name as its source.
173
+ if (entry.kind === 'server') return t('capabilities.source.mcp')
174
+ const source = entry.source ?? namespaceSource(entry)
175
+ const servers = mcpServersOf(source)
176
+ if (servers.length > 0) return servers.join(t('capabilities.source.separator'))
177
+ if (source.kind === 'skill') {
178
+ return SKILL_SOURCE_BUCKETS.includes(source.bucket) ? t(`capabilities.source.${source.bucket}`)
179
+ : source.bucket ? `${t('capabilities.source.other-skill')} · ${source.bucket}` : t('capabilities.source.skill')
180
+ }
181
+ if (source.kind === 'unknown') return t('capabilities.source.multiple-servers')
182
+ // A source kind this build does not name falls back to the namespace the identity
183
+ // states, so each row keeps naming its own layer instead of another one's.
184
+ const namespace = namespaceSource(entry).kind
185
+ if (source.kind === 'mcp' || namespace === 'mcp') return t('capabilities.source.mcp')
186
+ return t(`capabilities.source.${namespace === 'skill' ? 'skill' : 'host'}`)
187
+ }
188
+
69
189
  /** A Chat snapshot without a node store cannot order a receipt against the conversation. */
70
190
  const noSnapshot = () => undefined
71
191
 
@@ -89,17 +209,67 @@ export function firstHumanMessageSeq(nodes) {
89
209
 
90
210
  /**
91
211
  * A capability receipt belongs to chat only after the conversation has opened.
92
- * A blank Session has nothing to announce, and a receipt recorded before the
93
- * first human message describes a previous attempt's publication. A truncated
94
- * Chat window cannot prove where the conversation opened, so its earliest
95
- * loaded message must not hide a receipt the session may still own.
212
+ * A blank Session has nothing to announce, and a publication receipt recorded
213
+ * before the first human message describes a previous attempt's publication. A
214
+ * truncated Chat window cannot prove where the conversation opened, so its
215
+ * earliest loaded message must not hide a receipt the session may still own.
216
+ *
217
+ * A receipt that carries the record of a write is different: the user asked for
218
+ * that change through this session's own panel or command, so it stays visible
219
+ * wherever the conversation currently starts. Hiding it once is what made the
220
+ * row appear while the session was blank and vanish the moment a first message
221
+ * gave the window a starting point.
96
222
  */
97
- export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true } = {}) {
223
+ export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true,
224
+ written = false } = {}) {
98
225
  if (blank === true) return false
226
+ if (written === true) return true
99
227
  if (windowComplete !== true) return true
100
228
  return !(Number.isSafeInteger(node?.seq) && Number.isSafeInteger(firstUserSeq) && node.seq < firstUserSeq)
101
229
  }
102
230
 
231
+ /**
232
+ * One run of capability writes, as the card states it.
233
+ *
234
+ * The chat store is read once per node and the answer is a string, because a
235
+ * selector must return the same value for the same store: a fresh array would
236
+ * make every notification re-render. `node` names the member whose card this is;
237
+ * a store that does not contain it (a single-node render, or a window that does
238
+ * not reach it) still answers with that node's own row.
239
+ */
240
+ export function capabilityWriteRun(nodes, node, t) {
241
+ const own = writeReceiptOf(node)
242
+ if (own === undefined) return ''
243
+ const ownRow = capabilityReceiptNotice(t, own, 'card')
244
+ const list = orderedChatNodes(nodes)
245
+ const index = list.findIndex(item => item === node
246
+ || (node?.commandId !== undefined && item?.commandId === node.commandId))
247
+ if (index < 0) return JSON.stringify({ last: true, rows: [ownRow] })
248
+ let start = index
249
+ let end = index
250
+ while (start > 0 && writeReceiptOf(list[start - 1]) !== undefined) start -= 1
251
+ while (end + 1 < list.length && writeReceiptOf(list[end + 1]) !== undefined) end += 1
252
+ const rows = list.slice(start, end + 1).map(item => capabilityReceiptNotice(t, writeReceiptOf(item), 'card'))
253
+ return JSON.stringify({ last: end === index, rows })
254
+ }
255
+
256
+ function parseWriteRun(signature, fallback) {
257
+ if (typeof signature !== 'string' || signature === '') return fallback === '' ? { last: true, rows: [] } : JSON.parse(fallback)
258
+ try { return JSON.parse(signature) } catch { return { last: true, rows: [] } }
259
+ }
260
+
261
+ function orderedChatNodes(nodes) {
262
+ if (nodes === null || nodes === undefined) return []
263
+ if (typeof nodes.values === 'function') return [...nodes.values()]
264
+ return Array.isArray(nodes) ? nodes : []
265
+ }
266
+
267
+ function writeReceiptOf(node) {
268
+ if (typeof node?.args !== 'string' || !String(node.args).trim().startsWith('set ')) return undefined
269
+ const receipt = readCapabilityCommandReceipt(node?.outcome?.text)
270
+ return receipt?.state === 'recorded' && receipt.record !== undefined ? receipt : undefined
271
+ }
272
+
103
273
  export function createCapabilityCommandCard(React) {
104
274
  const h = React.createElement
105
275
  return function CapabilityCommandCard({ node, t, useChat, useSession }) {
@@ -118,38 +288,56 @@ export function createCapabilityCommandCard(React) {
118
288
  // not chat, so a node without that submitted input is not a user command and
119
289
  // renders no notice: a fresh session has no change to announce.
120
290
  if (typeof node.args !== 'string') return null
121
- if (!capabilityReceiptVisible(node, { blank, firstUserSeq, windowComplete })) return null
122
- const command = `/${node.name ?? 'capabilities'}${node.args ? ` ${String(node.args).trim()}` : ''}`
123
291
  const text = node.outcome?.text
124
292
  const receipt = readCapabilityCommandReceipt(text)
293
+ // A write is this session's own record, so it is visible wherever the window
294
+ // starts; a publication receipt keeps the conversation-start rule.
295
+ const written = receipt?.state === 'recorded' && receipt.record !== undefined
296
+ if (!capabilityReceiptVisible(node, { blank, firstUserSeq, windowComplete, written })) return null
297
+ const command = `/${node.name ?? 'capabilities'}${node.args ? ` ${String(node.args).trim()}` : ''}`
125
298
  // Pending is the plan's publication state, not an outcome: it becomes true
126
299
  // only at the next accepted model step, so a durable notice would state a
127
300
  // future that a historical row can no longer verify.
128
301
  if (receipt?.state === 'pending') return null
129
302
  const legacyRefresh = text === 'Session capability environment refreshed; the model-visible notification is recorded.'
303
+ if (written) {
304
+ // Writes that arrive together are one event for the reader: the runs are
305
+ // counted once and stated as one card, and the last row of a run draws it so
306
+ // the card does not repeat per command. The signature is a string because a
307
+ // selector has to answer the same value for the same store.
308
+ const signature = readChat(snapshot => capabilityWriteRun(snapshot?.nodes, node, t))
309
+ const run = parseWriteRun(signature, capabilityWriteRun(undefined, node, t))
310
+ if (run.last !== true) return null
311
+ return h('section', { className: 'ptcPlusCapabilityCommandNotice', 'data-kind': 'writes',
312
+ role: node.outcome?.kind === 'error' ? 'alert' : 'status' },
313
+ h('p', { className: 'ptcPlusCapabilityCommandNoticeHead' },
314
+ t('capabilities.card.writes', { count: run.rows.length })),
315
+ h('ul', { className: 'ptcPlusCapabilityCommandNoticeList' },
316
+ run.rows.map((row, index) => h('li', { key: `${index}`, className: 'ptcPlusCapabilityCommandNoticeRow' }, row))))
317
+ }
130
318
  if (receipt || String(node.args ?? '').trim() === 'refresh' || legacyRefresh) {
131
- const outcome = receipt ? t(`capabilities.card.${receipt.state}`, { error: receipt.error ?? '' })
319
+ // The notice a command leaves in chat is the same receipt the composer toast
320
+ // and the panel's footer state, so no surface keeps its own account of a write.
321
+ const outcome = receipt ? capabilityReceiptNotice(t, receipt, 'card')
132
322
  : legacyRefresh ? t('capabilities.card.recorded')
133
323
  : node.outcome ? (typeof text === 'string' && !text.trim() ? undefined : capabilityCommandText(t, text))
134
324
  ?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
135
325
  : t('capabilities.refreshing')
136
- // A command that names one capability carries that capability's three
137
- // facts; the notice states them beside its session outcome so "saved"
138
- // cannot be read as "delivered" or "usable".
139
- const notice = receipt?.proof === undefined || outcome === undefined
140
- ? outcome : `${outcome} ${capabilityProofCopy(t, receipt.proof)}`
141
326
  return h('p', { className: 'ptcPlusCapabilityCommandNotice',
142
327
  role: node.outcome?.kind === 'error' ? 'alert' : 'status',
143
- 'data-state': node.outcome?.kind ?? 'pending' }, notice)
328
+ 'data-state': node.outcome?.kind ?? 'pending' }, outcome)
144
329
  }
145
330
  let json
146
331
  if (typeof text === 'string') {
147
332
  try { json = JSON.parse(text) } catch { /* Plain command outcomes remain readable text. */ }
148
333
  }
334
+ // The listing states the saved column and this session's record; the composition
335
+ // a runtime would build is not one of its entries, so the card states these two
336
+ // columns and no third answer it could only guess.
149
337
  const entries = json?.schema === 'ptc-capability-catalog/v1' && Array.isArray(json.entries)
150
338
  && json.entries.every(entry => entry && typeof entry.id === 'string'
151
- && ['name', 'description', 'effective', 'saved', 'temporary']
152
- .every(key => entry[key] === undefined || typeof entry[key] === 'string')
339
+ && ['name', 'description'].every(key => entry[key] === undefined || typeof entry[key] === 'string')
340
+ && ['saved', 'temporary'].every(key => entry[key] === undefined || validCatalogStatement(key, entry[key]))
153
341
  && ['savedRule', 'temporaryRule'].every(key => entry[key] === undefined || validCatalogRule(entry[key], key === 'temporaryRule'))) ? json.entries : null
154
342
  const outcome = entries ? t('capabilities.card.catalogCount', { count: entries.length })
155
343
  : json ? t('capabilities.card.details')
@@ -157,13 +345,10 @@ export function createCapabilityCommandCard(React) {
157
345
  ?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
158
346
  : t('capabilities.refreshing')
159
347
  const label = (entry, key) => {
160
- const ruleKey = key === 'saved' ? 'savedRule' : key === 'temporary' ? 'temporaryRule' : 'effectiveDimensions'
161
- const rule = entry[ruleKey]
162
- if (rule !== undefined && (key !== 'effective' || validCatalogRule({ availability: rule?.availability, disclosure: rule?.disclosure }))) {
163
- const dimensions = capabilityRuleLabel(t, rule, key === 'temporary' ? 'capabilities.quick.noOverride' : 'capabilities.policy.inheritRule')
164
- return key === 'effective' ? `${capabilityChoiceLabel(t, entry.effective)} (${dimensions})` : dimensions
165
- }
166
- return capabilityChoiceLabel(t, entry[key])
348
+ if (typeof entry[key] === 'object') return catalogStatementLabel(t, entry[key])
349
+ const rule = entry[key === 'saved' ? 'savedRule' : 'temporaryRule']
350
+ return rule === undefined ? capabilityChoiceLabel(t, entry[key])
351
+ : capabilityRuleLabel(t, rule, key === 'temporary' ? 'capabilities.quick.noOverride' : 'capabilities.policy.layerAbove')
167
352
  }
168
353
  return h('section', { className: 'ptcPlusCapabilityReceipt', 'aria-label': t('capabilities.receipt.title') },
169
354
  h('div', { className: 'ptcPlusCapabilityReceiptHead' },
@@ -175,12 +360,12 @@ export function createCapabilityCommandCard(React) {
175
360
  entries?.length ? h('details', { className: 'ptcPlusCapabilityReceiptCatalog' },
176
361
  h('summary', null, t('capabilities.card.browse')),
177
362
  h('div', { className: 'ptcPlusCapabilityReceiptTable' }, h('table', null,
178
- h('thead', null, h('tr', null, ...['member', 'quick.effective', 'quick.globalSaved', 'quick.temporary']
363
+ h('thead', null, h('tr', null, ...['member', 'quick.globalSaved', 'quick.temporary']
179
364
  .map(key => h('th', { key, scope: 'col' }, t(`capabilities.${key}`))))),
180
365
  h('tbody', null, ...entries.map((entry, index) => h('tr', { key: `${entry.id}-${index}` },
181
366
  h('td', null, h('strong', null, entry.name ?? entry.id),
182
367
  entry.description ? h('small', null, entry.description) : null),
183
- ...['effective', 'saved', 'temporary'].map(key => h('td', { key }, label(entry, key))))))))) : null,
368
+ ...['saved', 'temporary'].map(key => h('td', { key }, label(entry, key))))))))) : null,
184
369
  text ? h('details', { className: 'ptcPlusCapabilityReceiptSource',
185
370
  onToggle: event => setShowSource(event.currentTarget.open) },
186
371
  h('summary', null, t('capabilities.card.source')),
@@ -189,28 +374,8 @@ export function createCapabilityCommandCard(React) {
189
374
  }
190
375
 
191
376
  const selections = ['automatic', 'full', 'on-demand', 'closed']
192
- const kinds = ['all', 'tool', 'skill', 'resource', 'other']
193
377
  const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
194
378
  const configurationId = () => Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
195
-
196
- /** Catalog facts and saved preferences stay separate from frozen session results. */
197
- export function capabilitySettingsEntries(value, catalog, plans, uncertain = false) {
198
- const baseline = JSON.parse(value.capabilityBaseline ?? CONFIG_DEFAULTS.capabilityBaseline)
199
- const entries = new Map((catalog ?? []).map(entry => [entry.id, { ...entry, available: true }]))
200
- for (const plan of plans ?? []) for (const entry of plan.effective.entries) {
201
- if (!entries.has(entry.id)) entries.set(entry.id, { id: entry.id, category: entry.category })
202
- }
203
- const policy = capabilityPolicyForConfig(value)
204
- for (const id of [...baseline, ...Object.keys(policy.items)].filter(id => !id.startsWith('mcpServers.'))) if (!entries.has(id)) entries.set(id, { id })
205
- return [...entries.values()].map(entry => ({ ...entry,
206
- name: entry.name ?? entry.id,
207
- kind: ['tool', 'skill'].includes(entry.category) ? entry.category
208
- : ['resource', 'resource-template'].includes(entry.category) ? 'resource' : 'other',
209
- availability: catalog === null || uncertain ? 'unknown' : entry.available ? 'available' : 'unavailable',
210
- common: baseline.includes(entry.id), rule: policy.items[entry.id] ?? {}, resolvedRule: capabilityRule(policy, entry),
211
- }))
212
- }
213
-
214
379
  export function capabilityPlanReason(t, entry) {
215
380
  const known = ['manual-override', 'baseline-not-selected', 'baseline', 'coverage-below-threshold', 'statistical-coverage',
216
381
  'mcp-server-disabled', 'contract-budget', 'management-disabled', 'statistics-disabled', 'statistics-settings-invalid', 'statistics-data-invalid',
@@ -219,7 +384,7 @@ export function capabilityPlanReason(t, entry) {
219
384
  .map(reason => known.includes(reason) ? t(`capabilities.reason.${reason}`) : reason).join(' · ')
220
385
  }
221
386
 
222
- export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal = createFallbackModal(React) }) {
387
+ export function createCapabilitySettingsViews(React, { Select, Modal = createFallbackModal(React) }) {
223
388
  const h = React.createElement
224
389
  const field = key => CONFIG_FIELDS.find(item => item.key === key)
225
390
  // Each group owns one settings tab; the prompt text switch lives in the same
@@ -308,8 +473,13 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
308
473
  )
309
474
  }
310
475
 
311
- function PolicyRules({ value, catalog, disabled, onChange, t }) {
312
- const policy = capabilityPolicyForConfig(value)
476
+ function PolicyRules({ value, disabled, onChange, t }) {
477
+ // This window edits the record's own document. A configuration that saved no policy has only
478
+ // a synthesized document, whose bottom layer the capability-management switch shaped, so the
479
+ // editor starts from the record's written layers instead of freezing that layer into the
480
+ // record on the first edit (capability-surface §8.1c).
481
+ const policy = value.capabilityPolicySaved === true
482
+ ? capabilityPolicyForConfig(value) : capabilityPolicyFromRecord(value)
313
483
  const write = next => onChange(field('capabilityPolicy'), JSON.stringify(next))
314
484
  const ruleControls = (rule, update, label, defaults = false) => h('div', { className: 'ptcPlusPolicyRule', key: label },
315
485
  h('strong', null, label), ...['availability', 'disclosure'].map(dimension => h('div', { className: 'ptcPlusCapabilityFilter', key: dimension },
@@ -318,7 +488,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
318
488
  value: rule[dimension] ?? 'automatic', onChange: choice => {
319
489
  const next = { ...rule }; if (choice === 'automatic') delete next[dimension]; else next[dimension] = choice; update(next)
320
490
  }, options: [...(defaults ? [] : ['automatic']), ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
321
- .map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${choice === 'enabled' ? 'quick.enabled' : choice}`) })) }))))
491
+ .map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.layerAbove' : `capabilities.word.${choice}`) })) }))))
322
492
  return h('section', { className: 'ptcPlusPolicyRules', 'aria-label': t('capabilities.policy.rules') },
323
493
  h('h4', null, t('capabilities.inheritedRules')), h('p', { className: 'ptcPlusDetail' }, t('capabilities.inheritedRulesHelp')),
324
494
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.policy.precedence')),
@@ -327,7 +497,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
327
497
  ...Object.values(CAPABILITY_INVENTORY_CATEGORIES).map(category => ruleControls(policy.categories[category] ?? {}, rule =>
328
498
  write({ ...policy, categories: { ...policy.categories, [category]: rule } }), t(`capabilities.policy.category.${category}`)))),
329
499
  h('details', null, h('summary', { tabIndex: 0 }, t('capabilities.sourceRules')),
330
- ...[...new Set([...Object.keys(policy.mcpServers), ...(catalog ?? []).map(item => item.mcpServer).filter(Boolean)])].sort(compare)
500
+ ...[...new Set(Object.keys(policy.mcpServers))].sort(compare)
331
501
  .map(server => ruleControls(policy.mcpServers[server] ?? {}, rule =>
332
502
  write(changeCapabilityRule(changeCapabilityRule(policy, `mcpServers.${server}`, 'availability', rule.availability ?? 'automatic'),
333
503
  `mcpServers.${server}`, 'disclosure', rule.disclosure ?? 'automatic')), server))))
@@ -345,340 +515,23 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
345
515
  footer ? h('div', { className: 'ptcPlusCapabilityDialogFooter' }, footer) : null)
346
516
  }
347
517
 
348
- function CapabilityBatchDialog({ operation, value, entries, disabled, onChange, onClose, t }) {
349
- const [choices, setChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
350
- const [pending, setPending] = React.useState(false)
351
- const [failed, setFailed] = React.useState(false)
352
- const saving = React.useRef(false)
353
- const mounted = React.useRef(true)
354
- React.useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
355
- const targetIds = new Set(entries.filter(entry => entry.availability === 'available').map(entry => entry.id))
356
- const missing = operation.targets.filter(entry => !targetIds.has(entry.id)).length
357
- const blocked = disabled('capabilityPolicy') || pending
358
- const changed = Object.values(choices).some(choice => choice !== 'keep')
359
- const close = () => { if (!saving.current) onClose() }
360
- const confirm = async () => {
361
- if (saving.current || blocked || missing || !changed || !operation.targets.length) return
362
- let next = capabilityPolicyForConfig(value)
363
- for (const target of operation.targets) for (const dimension of ['availability', 'disclosure']) {
364
- if (choices[dimension] !== 'keep') next = changeCapabilityRule(next, target.id, dimension, choices[dimension])
365
- }
366
- saving.current = true; setPending(true); setFailed(false)
367
- try {
368
- const saved = await onChange(field('capabilityPolicy'), JSON.stringify(next))
369
- if (!mounted.current) return
370
- if (saved === true) onClose()
371
- else setFailed(true)
372
- } catch {
373
- if (mounted.current) setFailed(true)
374
- } finally {
375
- saving.current = false
376
- if (mounted.current) setPending(false)
377
- }
378
- }
379
- return h(Modal, { open: true, onClose: close, title: t('capabilities.batchSettings'),
380
- closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
381
- h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
382
- h('button', { type: 'button', className: 'ptcPlusDialogClose', disabled: pending,
383
- 'aria-label': t('capabilities.batch.cancel'), onClick: close }, '×')),
384
- h('div', { className: 'ptcPlusCapabilityBatchBody' },
385
- h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.targets', { count: operation.targets.length })),
386
- operation.excluded ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.excluded', { count: operation.excluded })) : null,
387
- operation.targets.length ? h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
388
- ...operation.targets.map(target => h('li', { key: target.id }, h('span', null, target.name), h('code', null, target.id))))
389
- : h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.empty')),
390
- h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
391
- h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
392
- h(Select, { value: choices[dimension], disabled: blocked || !operation.targets.length,
393
- label: t(`capabilities.batch.${dimension}`), onChange: choice => setChoices(current => ({ ...current, [dimension]: choice })),
394
- options: ['keep', 'automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
395
- .map(choice => ({ value: choice, label: t(choice === 'keep' ? 'capabilities.batch.keep'
396
- : choice === 'automatic' ? 'capabilities.policy.inheritRule'
397
- : choice === 'enabled' ? 'capabilities.action.enable'
398
- : choice === 'closed' ? 'capabilities.action.close' : `capabilities.${choice}`) })) })))),
399
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.effect')),
400
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sourceClosure')),
401
- missing ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.changedTargets', { count: missing })) : null,
402
- failed ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.failed')) : null),
403
- h('div', { className: 'ptcPlusCapabilityBatchFooter' },
404
- h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending, onClick: close }, t('capabilities.batch.cancel')),
405
- h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
406
- disabled: blocked || Boolean(missing) || !changed || !operation.targets.length, onClick: confirm },
407
- t(pending ? 'state.syncing' : 'capabilities.batch.confirm', { count: operation.targets.length }))))
408
- }
409
-
410
- function CapabilityChoices({ value, catalog, catalogError, plans, plansError, disabled, onChange, onReload, t, selectedSessionId, onSessionSelect }) {
411
- const root = React.useRef(null)
412
- const [query, setQuery] = React.useState('')
413
- const [kind, setKind] = React.useState('all')
414
- const [source, setSource] = React.useState('all')
415
- const [strategy, setStrategy] = React.useState('all')
416
- const [availabilityRule, setAvailabilityRule] = React.useState('all')
417
- const [onlyCommon, setOnlyCommon] = React.useState(false)
418
- const [availability, setAvailability] = React.useState('all')
419
- const [sort, setSort] = React.useState(null)
420
- const [selected, setSelected] = React.useState(null)
421
- const [picked, setPicked] = React.useState([])
422
- const [filtersOpen, setFiltersOpen] = React.useState(false)
423
- const [batchOperation, setBatchOperation] = React.useState(null)
424
- const [inheritedRulesOpen, setInheritedRulesOpen] = React.useState(false)
425
- const [localSessionId, setLocalSessionId] = React.useState('')
426
- const sessionId = onSessionSelect ? selectedSessionId : localSessionId
427
- const setSessionId = next => { setLocalSessionId(next); onSessionSelect?.(next) }
428
- const baseline = JSON.parse(value.capabilityBaseline ?? CONFIG_DEFAULTS.capabilityBaseline)
429
- const policy = capabilityPolicyForConfig(value)
430
- const entries = capabilitySettingsEntries(value, catalog, plans, Boolean(catalogError))
431
- const sourceKey = entry => JSON.stringify([entry.source?.kind ?? 'unknown', entry.source?.server ?? null,
432
- entry.source?.bucket ?? null, entry.source?.provider ?? null])
433
- const sourceLabel = entry => {
434
- if (entry.source?.kind === 'skill') {
435
- const bucket = entry.source.bucket
436
- const known = ['project-dsh', 'project-agents', 'user-dsh', 'user-agents', 'runtime', 'bundled', 'custom']
437
- const label = known.includes(bucket) ? t(`capabilities.source.${bucket}`)
438
- : bucket ? `${t('capabilities.source.other-skill')} · ${bucket}` : t('capabilities.source.unknown')
439
- return `${label}${entry.source.provider ? ` · ${entry.source.provider}` : ''}`
440
- }
441
- if (entry.source?.kind === 'mcp') return `MCP${entry.source.server ? ` · ${entry.source.server}` : ''}`
442
- return t(`capabilities.source.${entry.source?.kind === 'host' ? 'host' : 'unknown'}`)
443
- }
444
- const matchesKind = (entry, type) => type === 'all' || (type === 'resource' ? entry.source?.kind === 'mcp'
445
- : type === 'tool' ? entry.kind === 'tool' && entry.source?.kind !== 'mcp' : type === entry.kind)
446
- const sources = new Map(entries.filter(entry => matchesKind(entry, kind)).map(entry => [sourceKey(entry), sourceLabel(entry)]))
447
- const setType = next => { setKind(next); if (!entries.some(entry => matchesKind(entry, next) && sourceKey(entry) === source)) setSource('all') }
448
- const setCompatibleSource = next => {
449
- setSource(next)
450
- if (next !== 'all' && !entries.some(entry => matchesKind(entry, kind) && sourceKey(entry) === next)) setKind('all')
451
- }
452
- const normalizedQuery = query.trim().toLowerCase()
453
- const filtered = entries.filter(entry => matchesKind(entry, kind)
454
- && (source === 'all' || sourceKey(entry) === source)
455
- && (strategy === 'all' || (entry.rule.disclosure ?? 'automatic') === strategy)
456
- && (availabilityRule === 'all' || (entry.rule?.availability ?? 'automatic') === availabilityRule)
457
- && (!onlyCommon || entry.common)
458
- && (availability === 'all' || entry.availability === availability)
459
- && `${entry.name} ${entry.id} ${entry.description ?? ''} ${sourceLabel(entry)}`.toLowerCase().includes(normalizedQuery))
460
- const defaultOrder = (a, b) => compare(a.name.toLowerCase(), b.name.toLowerCase()) || compare(a.id, b.id)
461
- const state = item => item.availability === 'unknown' ? 'unknown' : item.availability === 'unavailable' ? 'unavailable'
462
- : value.capabilityContextEnabled === false || value.capabilityManagementEnabled !== true ? 'unapplied'
463
- : item.resolvedRule.availabilitySource.startsWith('mcp:') && item.resolvedRule.availability === 'closed' ? 'source-closed' : item.resolvedRule.availability
464
- const type = item => item.source?.kind === 'mcp' ? 'resource' : item.kind
465
- const displayedSource = item => item.source?.kind === 'mcp' ? item.source.server ?? sourceLabel(item) : sourceLabel(item)
466
- const groups = new Map()
467
- for (const entry of filtered) {
468
- const group = entry.source?.kind === 'mcp' && entry.source.server ? `mcp:${entry.source.server}`
469
- : entry.availability === 'unavailable' ? 'unavailable' : entry.kind
470
- if (!groups.has(group)) groups.set(group, [])
471
- groups.get(group).push(entry)
472
- }
473
- const groupOrder = ['tool', ...[...groups.keys()].filter(key => key.startsWith('mcp:')).sort(compare), 'skill', 'resource', 'other', 'unavailable']
474
- const defaultEntries = groupOrder.flatMap(group => (groups.get(group) ?? []).sort(defaultOrder))
475
- const visible = sortCapabilityEntries(defaultEntries, sort, (item, column) => column === 'name' ? item.name
476
- : column === 'type' ? t(`capabilities.type.${type(item)}`)
477
- : column === 'source' ? displayedSource(item)
478
- : column === 'availability' ? t(`capabilities.rowState.${state(item)}`)
479
- : t(`capabilities.${item.resolvedRule.disclosure}`))
480
- const pickedIds = picked.filter(id => entries.some(item => item.id === id))
481
- const pickedEntries = pickedIds.map(id => entries.find(item => item.id === id)).filter(Boolean)
482
- const pickedTargets = pickedEntries.filter(item => item.availability === 'available')
483
- const allVisiblePicked = visible.length > 0 && visible.every(item => pickedIds.includes(item.id))
484
- const someVisiblePicked = visible.some(item => pickedIds.includes(item.id))
485
- const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
486
- const toggleAllVisible = () => setPicked(current => allVisiblePicked
487
- ? current.filter(id => !visible.some(item => item.id === id))
488
- : [...new Set([...current, ...visible.map(item => item.id)])])
489
- const entry = visible.find(item => item.id === selected)
490
- const plan = sessionId ? plans?.find(item => item.sessionId === sessionId) : undefined
491
- const effective = plan?.effective.entries.find(item => item.id === entry?.id)
492
- const change = (key, next) => onChange(field(key), JSON.stringify(next))
493
- const setManual = (id, selection) => change('capabilityPolicy', changeCapabilityRule(policy, id, 'disclosure', selection))
494
- const clear = () => { setQuery(''); setKind('all'); setSource('all'); setStrategy('all'); setAvailabilityRule('all'); setOnlyCommon(false); setAvailability('all') }
495
- const activeFilters = Number(kind !== 'all') + Number(source !== 'all') + Number(strategy !== 'all') + Number(availabilityRule !== 'all') + Number(onlyCommon) + Number(availability !== 'all')
496
- const select = (label, current, update, options, blocked = false) => h('div', { className: 'ptcPlusCapabilityFilter' },
497
- h('span', null, label), h(Select, { label, value: current, disabled: blocked, onChange: update,
498
- options: options.map(([key, text]) => ({ value: key, label: text })) }))
499
- const editorField = (label, control) => h('div', { className: 'ptcPlusCapabilityField' }, h('span', null, label), control)
500
- const manualSelect = item => h('div', { className: 'ptcPlusCapabilityEditorFields' },
501
- editorField(t('capabilities.enabledColumn'), h(Select, { value: item.rule.availability ?? 'automatic', disabled: disabled('capabilityPolicy'),
502
- label: t('capabilities.policy.availabilityFor', { name: item.id }),
503
- onChange: next => change('capabilityPolicy', changeCapabilityRule(policy, item.id, 'availability', next)),
504
- options: ['automatic', 'enabled', 'closed'].map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : choice === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${choice}`) })) })),
505
- editorField(t('capabilities.contextColumn'), h(Select, { value: item.rule.disclosure ?? 'automatic', disabled: disabled('capabilityPolicy'),
506
- label: t('capabilities.disclosureFor', { name: item.id }),
507
- onChange: next => setManual(item.id, next), options: ['automatic', ...CAPABILITY_DISCLOSURES]
508
- .map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${choice}`) })) })))
509
- const commonSwitch = item => h('div', { className: 'ptcPlusCapabilitySwitchRow' },
510
- h('div', { className: 'ptcPlusCapabilitySwitchText' },
511
- h('span', { className: 'ptcPlusCapabilitySwitchLabel' }, t('capabilities.commonBaseline')),
512
- h('span', { className: 'ptcPlusCapabilitySwitchHint' }, t(item.common ? 'capabilities.inBaseline' : 'capabilities.notInBaseline'))),
513
- h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', checked: item.common,
514
- disabled: disabled('capabilityBaseline'), 'aria-label': t('capabilities.baselineFor', { name: item.id }),
515
- onChange: event => change('capabilityBaseline', event.target.checked
516
- ? [...baseline.filter(id => id !== item.id), item.id] : baseline.filter(id => id !== item.id)) }))
517
- const filterLabels = [kind !== 'all' ? t(`capabilities.type.${kind}`) : null,
518
- source !== 'all' ? sources.get(source) ?? t('capabilities.previousSource') : null,
519
- strategy !== 'all' ? t(strategy === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${strategy}`) : null,
520
- availabilityRule !== 'all' ? t(availabilityRule === 'automatic' ? 'capabilities.policy.inheritRule'
521
- : availabilityRule === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed') : null,
522
- onlyCommon ? t('capabilities.onlyCommon') : null,
523
- availability !== 'all' ? t(`capabilities.availability.${availability}`) : null].filter(Boolean)
524
- const closeEditor = () => {
525
- const button = [...(root.current?.querySelectorAll('[data-capability-edit-id]') ?? [])].find(item => item.dataset.capabilityEditId === selected)
526
- setSelected(null); button?.focus({ preventScroll: true })
527
- }
528
- return h('div', { className: 'ptcPlusCapabilityChoices', ref: root },
529
- h('div', { className: 'ptcPlusCapabilityToolbar' },
530
- h('label', { className: 'ptcPlusSearch' }, h(SearchIcon),
531
- h('input', { type: 'search', value: query, 'aria-label': t('capabilities.search'), placeholder: t('capabilities.search'),
532
- onChange: event => setQuery(event.target.value) })),
533
- h('div', { className: 'ptcPlusCapabilityTypes', role: 'group', 'aria-label': t('capabilities.types') },
534
- ...kinds.filter(key => key !== 'other' || entries.some(item => item.kind === 'other')).map(key => h('button', {
535
- key, type: 'button', 'aria-pressed': kind === key, onClick: () => setType(key),
536
- }, t(`capabilities.type.${key}`)))),
537
- h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
538
- onClick: () => setFiltersOpen(true) }, t('capabilities.filterAction')),
539
- h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
540
- 'aria-label': t('capabilities.batchSettings'),
541
- disabled: disabled('capabilityPolicy') || pickedTargets.length === 0,
542
- onClick: () => setBatchOperation({
543
- targets: pickedTargets.map(item => ({ id: item.id, name: item.name })),
544
- excluded: pickedIds.length - pickedTargets.length,
545
- }) }, t('capabilities.batchSettings'),
546
- pickedIds.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
547
- title: t('capabilities.selectionCount', { count: pickedIds.length }) },
548
- String(pickedIds.length)) : null),
549
- h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
550
- onClick: () => setInheritedRulesOpen(true) }, t('capabilities.inheritedRules'))),
551
- filtersOpen ? h(CapabilityDialog, { title: t('capabilities.filterAction'), onClose: () => setFiltersOpen(false), t,
552
- className: 'ptcPlusCapabilityFilterDialog', footer: h('button', { type: 'button', className: 'ptcPlusButton',
553
- 'data-kind': 'primary', onClick: () => setFiltersOpen(false) }, t('capabilities.dialogDone')) },
554
- h('div', { className: 'ptcPlusCapabilityFilterFields' },
555
- select(t('capabilities.sourceFilter'), source, setCompatibleSource, [['all', t('capabilities.allSources')], ...sources,
556
- ...(source !== 'all' && !sources.has(source) ? [[source, t('capabilities.previousSource')]] : [])]),
557
- select(t('capabilities.policy.disclosureFilter'), strategy, setStrategy, [['all', t('capabilities.allStrategies')],
558
- ...['automatic', ...CAPABILITY_DISCLOSURES].map(key => [key, t(key === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${key}`)])]),
559
- select(t('capabilities.policy.availabilityFilter'), availabilityRule, setAvailabilityRule, [['all', t('capabilities.allStrategies')],
560
- ...['automatic', 'enabled', 'closed'].map(key => [key, t(key === 'automatic' ? 'capabilities.policy.inheritRule' : key === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed')])]),
561
- h('label', { className: 'ptcPlusCapabilityOnlyCommon' }, h('input', { type: 'checkbox', checked: onlyCommon,
562
- onChange: event => setOnlyCommon(event.target.checked) }), t('capabilities.onlyCommon')),
563
- select(t('capabilities.availabilityFilter'), availability, setAvailability,
564
- ['all', 'available', 'unavailable', 'unknown'].map(key => [key, t(`capabilities.availability.${key}`)])))) : null,
565
- inheritedRulesOpen ? h(CapabilityDialog, { title: t('capabilities.inheritedRules'), onClose: () => setInheritedRulesOpen(false), t,
566
- footer: h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
567
- onClick: () => setInheritedRulesOpen(false) }, t('capabilities.dialogDone')) },
568
- h(PolicyRules, { value, catalog, disabled, onChange, t })) : null,
569
- batchOperation ? h(CapabilityBatchDialog, { operation: batchOperation, value, entries, disabled, onChange,
570
- onClose: () => setBatchOperation(null), t }) : null,
571
- activeFilters ? h('div', { className: 'ptcPlusActiveFilters' }, h('span', null, filterLabels.join(' · ')),
572
- h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: clear }, t('capabilities.clear'))) : null,
573
- h('div', { className: 'ptcPlusCapabilityResultLine' }, h('span', { role: 'status' }, t('capabilities.results', { count: visible.length, total: entries.length })),
574
- h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: onReload }, t('capabilities.reloadCatalog'))),
575
- catalogError ? h('p', { role: 'status', className: 'ptcPlusMessage' }, t('capabilities.catalogUnavailable', { error: catalogError })) : null,
576
- entries.length === 0 ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.catalogEmpty'))
577
- : visible.length === 0 ? h('div', { className: 'ptcPlusCapabilityEmpty' }, h('p', null, t('capabilities.noMatches')),
578
- h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: clear }, t('capabilities.clear'))) :
579
- h('div', { className: 'ptcPlusCapabilityTable', role: 'table', 'aria-label': t('capabilities.management') },
580
- h('div', { className: 'ptcPlusCapabilityColumns', role: 'row' },
581
- ...CAPABILITY_SORT_COLUMNS.map(column => h('span', { key: column.key, role: 'columnheader',
582
- 'aria-sort': capabilitySortDirection(sort, column.key),
583
- ...(column.key === 'name' ? { 'aria-label': t('capabilities.member'), className: 'ptcPlusCapabilityPickHead' } : {}) },
584
- ...(column.key === 'name' ? [h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: allVisiblePicked,
585
- ref: node => { if (node) node.indeterminate = someVisiblePicked && !allVisiblePicked },
586
- disabled: visible.length === 0, 'aria-label': t('capabilities.selectAllVisible'),
587
- onChange: toggleAllVisible })] : []),
588
- h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
589
- onClick: () => setSort(current => cycleCapabilitySort(current, column.key)) },
590
- t(`capabilities.${column.label}`), h('span', { 'aria-hidden': true },
591
- capabilitySortDirection(sort, column.key) === 'ascending' ? '↑' : capabilitySortDirection(sort, column.key) === 'descending' ? '↓' : '↕'))))),
592
- h('div', { className: 'ptcPlusCapabilityList', role: 'rowgroup' }, ...visible.map(item => h('div', { key: item.id, className: 'ptcPlusCapabilityItem', role: 'row',
593
- 'data-selected': entry?.id === item.id, 'data-availability': item.availability,
594
- onClick: () => setSelected(item.id) },
595
- h('div', { className: 'ptcPlusCapabilityIdentity', role: 'cell' },
596
- h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: pickedIds.includes(item.id),
597
- 'aria-label': t('capabilities.selectForBatch', { name: item.name ?? item.id }),
598
- onClick: event => event.stopPropagation(), onChange: () => togglePick(item.id) }),
599
- h('div', { className: 'ptcPlusCapabilityIdentityText' },
600
- h('span', { className: 'ptcPlusCapabilityName' }, item.name), h('code', null, item.id))),
601
- h('span', { className: 'ptcPlusCapabilityBadge', role: 'cell', 'data-kind': type(item) }, t(`capabilities.type.${type(item)}`)),
602
- h('span', { className: 'ptcPlusCapabilitySource', role: 'cell' }, displayedSource(item)),
603
- // The cell stays a plain cell so its empty area selects the row; only the switch and
604
- // its own label swallow the click, keeping a toggle from also opening the row editor.
605
- h('div', { className: 'ptcPlusCapabilityAvailability', role: 'cell', 'data-state': state(item) },
606
- h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', id: `ptcPlusCapabilityAvailability-${item.id}`,
607
- checked: item.resolvedRule.availability === 'enabled',
608
- disabled: disabled('capabilityPolicy') || state(item) === 'source-closed',
609
- 'aria-label': t('capabilities.policy.availabilityFor', { name: item.id }),
610
- onClick: event => event.stopPropagation(),
611
- onChange: event => change('capabilityPolicy', changeCapabilityRule(policy, item.id, 'availability', event.target.checked ? 'enabled' : 'closed')) }),
612
- h('label', { htmlFor: `ptcPlusCapabilityAvailability-${item.id}`,
613
- onClick: event => event.stopPropagation() }, t(`capabilities.rowState.${state(item)}`))),
614
- h('div', { role: 'cell', className: 'ptcPlusCapabilityContextCell' }, h('button', { type: 'button', className: 'ptcPlusCapabilityContext', 'data-capability-edit-id': item.id,
615
- 'aria-label': t('capabilities.editContextFor', { name: item.id }), 'aria-haspopup': 'dialog',
616
- onClick: event => { event.stopPropagation(); setSelected(item.id) } },
617
- h('span', null, t(`capabilities.${item.resolvedRule.disclosure}`)),
618
- h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, 'aria-hidden': true },
619
- h('path', { d: 'm15 5 4 4M4 20l4-1L20 7l-4-4L4 15Z' })))))))),
620
- entry ? h(CapabilityDialog, { title: entry.name, onClose: closeEditor, t, className: 'ptcPlusCapabilityEditorDialog',
621
- footer: h('button', { type: 'button', className: 'ptcPlusButton', onClick: closeEditor }, t('capabilities.closeDetails')) },
622
- h('code', null, entry.id), h('p', { className: 'ptcPlusDetail' }, t('capabilities.contextHelp')),
623
- manualSelect(entry), commonSwitch(entry),
624
- h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
625
- : value.capabilityManagementEnabled !== true ? 'capabilities.effect.rulesOff'
626
- : entry.resolvedRule.availabilitySource.startsWith('mcp:') && entry.resolvedRule.availability === 'closed' ? 'capabilities.effect.sourceClosed'
627
- : entry.resolvedRule.availability === 'closed' ? 'capabilities.effect.closed'
628
- : `capabilities.effect.disclosure.${entry.resolvedRule.disclosure}`)),
629
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.policy.resolved', {
630
- availability: t(entry.resolvedRule.availability === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed'),
631
- disclosure: t(`capabilities.${entry.resolvedRule.disclosure}`),
632
- source: `${entry.resolvedRule.availabilitySource} · ${entry.resolvedRule.disclosureSource}` })),
633
- entry.rule.disclosure !== undefined ? h('button', { type: 'button', className: 'ptcPlusCapabilityLink', disabled: disabled('capabilityPolicy'),
634
- onClick: () => setManual(entry.id, 'automatic') }, t('capabilities.restoreAutomatic')) : null,
635
- entry.source?.kind === 'mcp' && entry.mcpServer ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
636
- h('h5', null, t('capabilities.sourceRules')),
637
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.serverHint')),
638
- h('div', { className: 'ptcPlusCapabilityEditorFields' },
639
- ...['availability', 'disclosure'].map(dimension => editorField(t(`capabilities.policy.${dimension}`),
640
- h(Select, { value: policy.mcpServers[entry.mcpServer]?.[dimension] ?? 'automatic', disabled: disabled('capabilityPolicy'),
641
- label: `${entry.mcpServer} · ${t(`capabilities.policy.${dimension}`)}`,
642
- onChange: next => change('capabilityPolicy', changeCapabilityRule(policy, `mcpServers.${entry.mcpServer}`, dimension, next)),
643
- options: ['automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)].map(key => ({ value: key,
644
- label: t(key === 'automatic' ? 'capabilities.policy.inheritRule' : key === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${key}`) })) }))))) : null,
645
- entry.kind === 'skill' ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.skillDisclosure')) : null,
646
- entry.kind === 'skill' && entry.invocationConflict ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.invocation.varies')) : null,
647
- entry.kind === 'skill' && entry.invocation ? h('p', { className: 'ptcPlusDetail' },
648
- t(entry.invocation.modelInvocable ? entry.invocation.userInvocable ? 'capabilities.invocation.both'
649
- : 'capabilities.invocation.model' : 'capabilities.invocation.user', { name: entry.name })) : null,
650
- entry.description ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
651
- h('h5', null, t('capabilities.catalogDescription')), h('p', { className: 'ptcPlusCapabilityProse' }, entry.description)) : null,
652
- entry.availability === 'unavailable' ? h('button', { type: 'button', className: 'ptcPlusCapabilityLink',
653
- disabled: disabled('capabilityBaseline') || disabled('capabilityPolicy'), onClick: () => {
654
- const next = { ...policy, items: { ...policy.items } }; delete next.items[entry.id]; change('capabilityPolicy', next)
655
- if (baseline.includes(entry.id)) change('capabilityBaseline', baseline.filter(id => id !== entry.id))
656
- } }, t('capabilities.removeSaved')) : null,
657
- value.capabilityContextEnabled !== false && value.capabilityManagementEnabled === true ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
658
- h('h5', null, t('capabilities.currentSession')),
659
- editorField(t('capabilities.sessionSelector'), h(Select, { value: sessionId ?? '', label: t('capabilities.sessionSelector'), onChange: setSessionId,
660
- options: [{ value: '', label: t('capabilities.chooseSession') }, ...(plans ?? []).map(item => ({ value: item.sessionId, label: item.sessionId }))] })),
661
- plansError ? h('p', { role: 'status', className: 'ptcPlusDetail' }, t('capabilities.unavailable', { error: plansError }))
662
- : !plan ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.selectObservation')) : h(React.Fragment, null,
663
- h('p', null, effective ? t(`capabilities.${effective.selection}`) : t('capabilities.notInSession')),
664
- effective ? h('p', { className: 'ptcPlusDetail' }, capabilityPlanReason(t, effective)) : null,
665
- effective?.evidence ? h('p', { className: 'ptcPlusDetail' }, `${effective.evidence.used}/${effective.evidence.eligible} (${effective.evidence.scope})`) : null,
666
- plan.pendingRefresh ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.pending')) : null)) : null) : null)
667
-
668
- }
669
-
670
518
  function AllocationEvidence({ allocation, allocationError, allocationPending, selectedSessionId, statistics = false, onReload, t }) {
671
519
  const [statisticsScope, setStatisticsScope] = React.useState('workspace')
672
520
  if (allocationPending) return h('p', { role: 'status' }, t('state.syncing'))
673
521
  if (allocationError) return h('p', { role: 'status', className: 'ptcPlusMessage' }, t('capabilities.unavailable', { error: allocationError }))
674
- if (!allocation) return selectedSessionId ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.observationUnavailable')) : null
522
+ if (!allocation) return selectedSessionId ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.observationNotRead')) : null
675
523
  const { plan, coverage, configuration } = allocation
676
524
  const count = selection => plan.entries.filter(entry => entry.selection === selection).length
677
525
  const evidenceFor = item => coverage?.[item.id]?.[statisticsScope]
678
526
  const usable = evidence => evidence?.status === 'usable' && Number.isFinite(evidence.used) && Number.isFinite(evidence.eligible)
527
+ // Only the sample states the evidence itself reports have a name here. Any other
528
+ // value describes the samples the read did not deliver, never a capability state.
529
+ const SAMPLE_STATES = ['no-samples', 'pending-settlement', 'incomplete-window', 'capacity-exceeded']
530
+ const sampleText = evidence => SAMPLE_STATES.includes(evidence?.status)
531
+ ? t(`capabilities.statStatus.${evidence.status}`) : t('capabilities.statStatus.not-reported')
679
532
  const coverageText = evidence => usable(evidence) && Number.isFinite(evidence.coverage) ? `${Math.round(evidence.coverage * 100)}%`
680
533
  : usable(evidence) ? t('capabilities.statStatus.no-samples')
681
- : t(`capabilities.statStatus.${evidence?.status ?? 'unavailable'}`)
534
+ : sampleText(evidence)
682
535
  return h('section', { className: 'ptcPlusAllocationEvidence', 'aria-label': t(statistics ? 'capabilities.observedUsage' : 'capabilities.budgetPreview') },
683
536
  h('div', { className: 'ptcPlusCapabilityResultLine' }, h('h4', null, t(statistics ? 'capabilities.observedUsage' : 'capabilities.budgetPreview')),
684
537
  h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: onReload }, t('capabilities.reloadObservation'))),
@@ -689,14 +542,14 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
689
542
  !statistics ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.coldStartHint')) : null,
690
543
  !statistics ? h(React.Fragment, null,
691
544
  h('div', { className: 'ptcPlusAllocationMetrics' },
692
- ...['full', 'on-demand', 'closed'].map(selection => h('div', { key: selection }, h('strong', null, count(selection)), h('span', null, t(`capabilities.${selection}`)))),
545
+ ...['full', 'on-demand', 'closed'].map(selection => h('div', { key: selection }, h('strong', null, count(selection)), h('span', null, t(`capabilities.word.${selection}`)))),
693
546
  h('div', null, h('strong', null, `${plan.budgets.contractBytes} / ${configuration.capabilityBudgetBytes}`), h('span', null, 'UTF-8 bytes'))),
694
547
  h('progress', { max: configuration.capabilityBudgetBytes, value: Math.min(configuration.capabilityBudgetBytes, plan.budgets.contractBytes),
695
548
  'aria-label': t('capabilities.budgetPreview') }),
696
549
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.budget', { bytes: plan.budgets.contractBytes, overage: plan.budgets.overageBytes })),
697
550
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.directoryUsage', { bytes: plan.budgets.directoryBytes, budget: configuration.capabilityDirectoryBytes })),
698
551
  !plan.enabled ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.managementOff')) : null)
699
- : coverage === null ? h('p', { role: 'status' }, t('capabilities.statStatus.unavailable')) : null,
552
+ : coverage === null ? h('p', { role: 'status' }, t('capabilities.statStatus.not-reported')) : null,
700
553
  h('div', { className: 'ptcPlusCapabilityEvidenceTable' }, h('table', null,
701
554
  h('thead', null, h('tr', null, ...[t('capabilities.member'), ...(statistics
702
555
  ? [t('capabilities.usedFamilies'), t('capabilities.eligibleFamilies'), t('capabilities.coverageColumn')]
@@ -708,7 +561,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
708
561
  h('td', { key: 'eligible' }, usable(evidenceFor(item)) ? evidenceFor(item).eligible : '—'),
709
562
  h('td', { key: 'coverage' }, coverageText(evidenceFor(item)), usable(evidenceFor(item)) && Number.isFinite(evidenceFor(item).coverage)
710
563
  ? h('progress', { max: 1, value: evidenceFor(item).coverage, 'aria-label': t('capabilities.coverageFor', { name: item.id }) }) : null)]
711
- : [h('td', { key: 'selection' }, t(`capabilities.${entry?.selection ?? 'host'}`)), h('td', { key: 'reason' }, entry ? capabilityPlanReason(t, entry) : '—'), h('td', { key: 'size' }, `${item.contractBytes} B`)]))
564
+ : [h('td', { key: 'selection' }, t(`capabilities.word.${entry?.selection ?? 'host'}`)), h('td', { key: 'reason' }, entry ? capabilityPlanReason(t, entry) : '—'), h('td', { key: 'size' }, `${item.contractBytes} B`)]))
712
565
  })))))
713
566
  }
714
567
 
@@ -868,7 +721,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
868
721
  setting('group.capabilities', enabled('capabilityContextEnabled')),
869
722
  setting('capabilities.management', enabled('capabilityManagementEnabled')),
870
723
  setting('capabilities.statisticsEnabled', t(JSON.parse(configuration.capabilityStatistics).enabled ? 'capabilities.serverOn' : 'capabilities.serverOff')),
871
- setting('capabilities.defaultDisclosure', t(`capabilities.${policy.defaults.disclosure}`)),
724
+ setting('capabilities.defaultDisclosure', t(`capabilities.word.${policy.defaults.disclosure}`)),
872
725
  setting('capabilities.allocationTitle', ['baseline', 'statistics', 'budget'].filter(key => policy.allocation[key]).map(key => t(`capabilities.allocation.${key}`)).join(' · ') || '—'),
873
726
  setting('capabilities.recoveryEnabled', enabled('capabilityRecoveryEnabled')),
874
727
  setting('capabilities.previewEnabled', `${enabled('modelPreviewEnabled')}${configuration.modelPreviewEnabled ? ` · ${t('capabilities.estimatedTokens', { count: configuration.modelPreviewTokens })}` : ''}`),
@@ -901,14 +754,18 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
901
754
  const saved = choicesFor(configurationReady ? value : undefined)
902
755
  const retained = flagsFor(saved)
903
756
  const applied = flagsFor(effectiveCapabilitySettings(saved))
904
- const policy = capabilityPolicyForConfig(value)
757
+ // This pane states and writes the record's own rules: a configuration that saved no policy
758
+ // starts from the declared defaults plus the override record, so the synthesized bottom layer
759
+ // is neither shown as a saved default nor frozen into the record (§8.1c).
760
+ const policy = value.capabilityPolicySaved === true
761
+ ? capabilityPolicyForConfig(value) : capabilityPolicyFromRecord(value)
905
762
  const write = next => onChange(field('capabilityPolicy'), JSON.stringify(next))
906
763
  const adaptive = [policy.defaults, ...Object.values(policy.categories), ...Object.values(policy.mcpServers), ...Object.values(policy.items)]
907
764
  .some(rule => rule.disclosure === 'adaptive')
908
765
  const summary = key => {
909
766
  if (choiceState(retained, applied, key) === 'unknown') return t('capabilities.combination.unknownSettings')
910
767
  if (key === 'description') {
911
- const description = t('capabilities.context.summary.description', { disclosure: t(`capabilities.${policy.defaults.disclosure}`) })
768
+ const description = t('capabilities.context.summary.description', { disclosure: t(`capabilities.word.${policy.defaults.disclosure}`) })
912
769
  if (policy.defaults.disclosure !== 'adaptive') return description
913
770
  const criteria = ['baseline', 'statistics', 'budget'].filter(key => policy.allocation[key])
914
771
  .map(key => t(`capabilities.allocation.${key}`)).join(t('capabilities.combination.separator'))
@@ -955,7 +812,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
955
812
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.descriptionDependency')),
956
813
  h('div', { className: 'ptcPlusCapabilityFilter' }, h('span', null, t('capabilities.defaultDisclosure')),
957
814
  h(Select, { value: policy.defaults.disclosure, disabled: disabled('capabilityPolicy'), label: t('capabilities.defaultDisclosure'),
958
- options: CAPABILITY_DISCLOSURES.map(choice => ({ value: choice, label: t(`capabilities.${choice}`) })),
815
+ options: CAPABILITY_DISCLOSURES.map(choice => ({ value: choice, label: t(`capabilities.word.${choice}`) })),
959
816
  onChange: disclosure => write({ ...policy, defaults: { ...policy.defaults, disclosure } }) })),
960
817
  h('p', { className: 'ptcPlusDetail' }, effect(value.capabilityManagementEnabled !== true ? 'capabilities.effect.rulesOff' : `capabilities.effect.disclosure.${policy.defaults.disclosure}`)),
961
818
  criteria,
@@ -1344,10 +1201,11 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
1344
1201
  h('p', { className: 'ptcPlusPromptIntro' }, t('capabilities.prompt.intro')))
1345
1202
  }
1346
1203
 
1347
- function CapabilityWorkbench({ value, configurationReady, catalog, catalogError, plans, plansError, plansPending, plansStale, disabled, onChange, onReload, t, renderField, renderPlans, renderRefresh, id, selectedSessionId, onSessionSelect,
1348
- allocation, allocationError, allocationPending, onApplyConfiguration, configurationWritable, saving, saveStatus }) {
1204
+ function CapabilityWorkbench({ value, configurationReady, plans, plansError, plansPending, plansStale, disabled, onChange, onReload, t, renderField, renderPlans, renderRefresh, id, selectedSessionId, onSessionSelect,
1205
+ allocation, allocationError, allocationPending, onApplyConfiguration, configurationWritable, saving, saveStatus, surface }) {
1349
1206
  const [tab, setTab] = React.useState('manual')
1350
1207
  const [contextSection, setContextSection] = React.useState('description')
1208
+ const [rulesOpen, setRulesOpen] = React.useState(false)
1351
1209
  const tabs = ['manual', 'budgets', 'statistics', 'prompt', 'plans']
1352
1210
  const feedback = saving ? t('state.syncing') : saveStatus && saveStatus.key !== 'status.applied' ? t(saveStatus.key, saveStatus.params)
1353
1211
  : t(configurationWritable ? 'capabilities.savedForNew' : 'footer.readOnly')
@@ -1371,7 +1229,18 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
1371
1229
  onChange: event => onChange(field('capabilityManagementEnabled'), event.target.checked) })) : null),
1372
1230
  key === 'manual' ? h(React.Fragment, null, h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
1373
1231
  : value.capabilityManagementEnabled === true ? 'capabilities.managementHelp' : 'capabilities.effect.rulesOff')),
1374
- h(CapabilityChoices, { value, catalog, catalogError, plans, plansError, disabled, onChange, onReload, t, selectedSessionId, onSessionSelect }))
1232
+ // The capability page is one renderer mounted here with the address this
1233
+ // entrance fixes. This entrance brings no columns, words, controls or
1234
+ // receipts of its own; the rule chain that answers for objects the saved
1235
+ // address has not decided one by one stays a rule editor beside it.
1236
+ h('div', { className: 'ptcPlusCapabilityRulesEntry' },
1237
+ h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
1238
+ onClick: () => setRulesOpen(true) }, t('capabilities.inheritedRules'))),
1239
+ rulesOpen ? h(CapabilityDialog, { title: t('capabilities.inheritedRules'), onClose: () => setRulesOpen(false), t,
1240
+ footer: h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
1241
+ onClick: () => setRulesOpen(false) }, t('capabilities.dialogDone')) },
1242
+ h(PolicyRules, { value, disabled, onChange, t })) : null,
1243
+ surface ?? null)
1375
1244
  : key === 'statistics' ? h(React.Fragment, null, renderField('capabilityStatistics'),
1376
1245
  h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
1377
1246
  : choicesFor(value).statistics?.enabled === true ? 'capabilities.effect.collectionOn'
@@ -1390,21 +1259,28 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
1390
1259
  return { CapabilityWorkbench }
1391
1260
  }
1392
1261
 
1393
- /** Execute a human command through DSH; a receipt never stands in for model publication. */
1262
+ /**
1263
+ * Execute a human command through DSH; a receipt never stands in for model publication.
1264
+ *
1265
+ * The button asks the command channel to run the same `refresh` line a human types.
1266
+ * The channel publishes nothing until a session observes it, so the button keeps its
1267
+ * own observation open; that observation decides no branch here. Whether the session
1268
+ * lists the command is the command's own answer: a refusal arrives as its concrete
1269
+ * error in the receipt, which is more accurate than a pre-emptive verdict.
1270
+ */
1394
1271
  export function createCapabilityRefreshView(React, commands) {
1395
1272
  const h = React.createElement
1396
- const absent = Object.freeze({ getSnapshot: () => false, subscribe: () => () => {} })
1397
1273
  return function CapabilityRefreshButton({ sessionId, enabled, t, onApplied, onInvalidated }) {
1398
- const source = React.useMemo(() => sessionId ? commands.source(sessionId) : absent, [sessionId])
1399
- const available = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
1400
1274
  const [pending, setPending] = React.useState(false)
1401
1275
  const [status, setStatus] = React.useState(null)
1402
1276
  const request = React.useRef(null)
1277
+ const source = React.useMemo(() => sessionId ? commands.source(sessionId) : null, [sessionId])
1278
+ React.useEffect(() => source === null ? undefined : source.subscribe(() => {}), [source])
1403
1279
  React.useEffect(() => {
1404
1280
  setStatus(null)
1405
1281
  setPending(false)
1406
1282
  return () => { request.current?.abort(); request.current = null }
1407
- }, [sessionId, enabled, available])
1283
+ }, [sessionId, enabled])
1408
1284
  const cancel = event => {
1409
1285
  event.stopPropagation()
1410
1286
  request.current?.abort(); request.current = null; setPending(false)
@@ -1413,7 +1289,7 @@ export function createCapabilityRefreshView(React, commands) {
1413
1289
  }
1414
1290
  const execute = async event => {
1415
1291
  event.stopPropagation()
1416
- if (!enabled || !available || request.current) return
1292
+ if (!enabled || request.current) return
1417
1293
  const controller = new AbortController()
1418
1294
  request.current = controller
1419
1295
  setPending(true)
@@ -1437,596 +1313,643 @@ export function createCapabilityRefreshView(React, commands) {
1437
1313
  }
1438
1314
  }
1439
1315
  return h('div', { className: 'ptcPlusCapabilityRefresh' },
1440
- h('button', { type: 'button', className: 'ptcPlusButton', disabled: !enabled || !available || pending,
1441
- title: !available ? t(sessionId ? 'capabilities.refreshUnavailable' : 'capabilities.chooseSession') : undefined,
1316
+ h('button', { type: 'button', className: 'ptcPlusButton', disabled: !enabled || pending,
1317
+ title: sessionId ? undefined : t('capabilities.chooseSession'),
1442
1318
  onClick: execute }, t(pending ? 'capabilities.refreshing' : 'capabilities.applySession')),
1443
1319
  pending ? h('button', { type: 'button', className: 'ptcPlusButton', onClick: cancel }, t('capabilities.cancelRefresh')) : null,
1444
1320
  status ? h('p', { className: 'ptcPlusDetail', role: 'status' }, capabilityCommandText(t, status))
1445
- : !available ? h('p', { className: 'ptcPlusDetail' }, t(sessionId ? 'capabilities.refreshUnavailable' : 'capabilities.chooseSession')) : null)
1321
+ : sessionId ? null : h('p', { className: 'ptcPlusDetail' }, t('capabilities.chooseSession')))
1446
1322
  }
1447
1323
  }
1448
1324
 
1449
- /** Session capability choices use the real command transaction and a read-only catalog observation. */
1450
- export function createSessionCapabilityControlsView(React, { commands, Modal, Select, inspectSessionCatalog }) {
1325
+ /**
1326
+ * The one capability surface, mounted by every entrance.
1327
+ *
1328
+ * A page states, for one supplied object, which value each of its two dimensions has
1329
+ * at each address. Three permanent facts decide what it may state: the Host's supply
1330
+ * (which rows exist), the record at each address (the saved configuration and this
1331
+ * session's own log), and the receipt a write leaves. No runtime, service or global
1332
+ * switch produces any of them, so the read is not gated: a failed read is a defect
1333
+ * reported beside the rows, never a state the rows are replaced with.
1334
+ *
1335
+ * An entrance fixes which address this page may write. §4: a control exists exactly
1336
+ * when its cell's address is fixed by the entrance that opened the page — so the
1337
+ * session entrance controls this session's record and the settings entrance controls
1338
+ * the new-session default. The other address is still stated, because it is what the
1339
+ * reader compares against, and the session entrance adds the receipt column, which
1340
+ * names the record each decision leaves. The settings entrance has no session address
1341
+ * at all: its two session columns are absent, not disabled and not read-only.
1342
+ */
1343
+ export function createCapabilitySurfaceView(React, { commands, bindingCommands, Modal, Select, inspectSessionCatalog }) {
1451
1344
  const h = React.createElement
1452
- const emptyDrafts = () => ({})
1453
- // The panel reports the three capability facts only where it holds evidence:
1454
- // the receipts its own commands returned. A catalogue row says what the plan
1455
- // will do; it cannot say a cell reached the capability.
1456
- const emptyProofs = () => ({})
1457
- return function SessionCapabilityControls({ sessionId, open, enabled, t, onClose }) {
1458
- const source = React.useMemo(() => commands.source(sessionId), [sessionId])
1459
- const available = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
1460
- const [catalog, setCatalog] = React.useState([])
1461
- const [management, setManagement] = React.useState('unknown')
1462
- const [savedSettings, setSavedSettings] = React.useState(null)
1463
- const [loading, setLoading] = React.useState(false)
1464
- const [error, setError] = React.useState(null)
1465
- const [drafts, setDrafts] = React.useState(emptyDrafts)
1345
+ // §4's seven columns plus the receipt column, in that order. One column, one
1346
+ // decision: a dimension's control owns a labelled cell instead of sharing an
1347
+ // unlabelled one with another dimension's control.
1348
+ const COLUMNS = Object.freeze({
1349
+ session: Object.freeze(['object', 'type', 'source', 'savedEnabled', 'savedDisclosure',
1350
+ 'sessionEnabled', 'sessionDisclosure', 'receipt']),
1351
+ settings: Object.freeze(['object', 'type', 'source', 'savedEnabled', 'savedDisclosure']),
1352
+ })
1353
+ // One word per column, declared by this page and by no other surface (§6).
1354
+ const COLUMN_COPY = Object.freeze({
1355
+ object: 'capabilities.column.object',
1356
+ type: 'capabilities.typesColumn',
1357
+ source: 'capabilities.sourceColumn',
1358
+ savedEnabled: 'capabilities.column.savedEnabled',
1359
+ savedDisclosure: 'capabilities.column.savedDisclosure',
1360
+ sessionEnabled: 'capabilities.column.sessionEnabled',
1361
+ sessionDisclosure: 'capabilities.column.sessionDisclosure',
1362
+ receipt: 'capabilities.column.receipt',
1363
+ })
1364
+ // The page's one value table. `inherit` is the session columns' own word for an
1365
+ // address that recorded nothing, so no cell is blank and none carries a denial;
1366
+ // every other word answers "who reads it, from which moment", which is the question
1367
+ // both disclosure columns ask.
1368
+ // `automatic` is the saved address's own word for a dimension its configuration wrote
1369
+ // no rule for; the bottom layer a chain would fall back to is synthesized from
1370
+ // configuration this page does not own, so it states no value here.
1371
+ const SAVED_AVAILABILITY = Object.freeze(['enabled', 'closed', 'automatic'])
1372
+ const SAVED_DISCLOSURE = Object.freeze([...CAPABILITY_DISCLOSURES, 'automatic'])
1373
+ // A session column states the record's own words, so the record's own `automatic` is one
1374
+ // of them; `inherit` stays this column's word for an address that recorded nothing.
1375
+ const SESSION_AVAILABILITY = Object.freeze(['enabled', 'closed', 'automatic', 'inherit'])
1376
+ const SESSION_DISCLOSURE = Object.freeze([...CAPABILITY_DISCLOSURES, 'automatic', 'inherit'])
1377
+ const DIMENSIONS = Object.freeze(['availability', 'disclosure'])
1378
+ const ADOPTION_WORDS = Object.freeze({ use: 'capabilities.word.use', skip: 'capabilities.word.skip',
1379
+ damaged: 'capabilities.word.damaged' })
1380
+ const FAMILIES = Object.freeze(['tools', 'skills', 'mcp', 'mcp_tools'])
1381
+
1382
+ /** A receipt is the command's own text; a keyed receipt is translated, never rewritten. */
1383
+ const receiptText = (t, result) => {
1384
+ const text = result?.text
1385
+ if (typeof text === 'string') return text
1386
+ if (text !== null && typeof text === 'object' && typeof text.key === 'string') return t(text.key, text.params)
1387
+ return text === undefined || text === null ? '' : String(text)
1388
+ }
1389
+
1390
+ return function CapabilitySurface({ entrance = 'session', sessionId, open, inline, writable = true, revision, savedCommands, onReload, t, onClose }) {
1391
+ const settingsEntrance = entrance === 'settings'
1392
+ const active = inline === true ? true : open
1393
+ const [rows, setRows] = React.useState([])
1394
+ // `loaded` is the read's own answer: an empty table means the Host declared
1395
+ // nothing only after a complete read said so.
1396
+ const [loaded, setLoaded] = React.useState(false)
1397
+ const [defect, setDefect] = React.useState(null)
1398
+ // The receipt of the most recent write, verbatim, or the error a refused write
1399
+ // returned. The panel owns no wording of its own here.
1400
+ const [receipt, setReceipt] = React.useState(null)
1401
+ const [writing, setWriting] = React.useState([])
1466
1402
  const [picked, setPicked] = React.useState([])
1467
1403
  const [batchOpen, setBatchOpen] = React.useState(false)
1468
1404
  const [batchChoices, setBatchChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
1469
- const [selectedId, setSelectedId] = React.useState(null)
1470
- const [pane, setPane] = React.useState('list')
1471
1405
  const [query, setQuery] = React.useState('')
1472
- const [kind, setKind] = React.useState('all')
1473
- const [sourceFilter, setSourceFilter] = React.useState('all')
1474
- const [stateFilter, setStateFilter] = React.useState('all')
1406
+ const [family, setFamily] = React.useState('all')
1407
+ const [source, setSource] = React.useState('all')
1408
+ // The table's ordering, owned by this page: a display decision that writes nothing
1409
+ // and is dropped with the session it was made in.
1475
1410
  const [sort, setSort] = React.useState(null)
1476
- const [focusDimension, setFocusDimension] = React.useState(null)
1477
- const [status, setStatus] = React.useState(null)
1478
- const [proofs, setProofs] = React.useState(emptyProofs)
1479
- const [pending, setPending] = React.useState(false)
1480
1411
  const request = React.useRef(null)
1481
- const applying = React.useRef(false)
1412
+ // Every write owns its own signal: a submitted command is not cancelled because
1413
+ // another row was used, and each row reports only its own progress.
1414
+ const writes = React.useRef(new Set())
1415
+ const epoch = React.useRef(0)
1482
1416
  const mounted = React.useRef(true)
1483
- const detail = React.useRef(null)
1484
- const list = React.useRef(null)
1485
- React.useEffect(() => {
1486
- if (!focusDimension) return
1487
- const field = focusDimension === 'list' ? list.current?.querySelector('[data-selected="true"] .ptcPlusSessionCapabilityName')
1488
- : detail.current?.querySelector(`[data-capability-dimension="${focusDimension}"] [role="combobox"]`)
1489
- ?? detail.current?.querySelector('[role="combobox"]')
1490
- field?.focus({ preventScroll: true })
1491
- setFocusDimension(null)
1492
- }, [focusDimension, selectedId, pane])
1493
1417
  React.useEffect(() => {
1494
1418
  mounted.current = true
1495
- return () => { mounted.current = false; request.current?.abort(); request.current = null }
1419
+ return () => {
1420
+ mounted.current = false
1421
+ epoch.current += 1
1422
+ request.current?.abort()
1423
+ request.current = null
1424
+ for (const controller of writes.current) controller.abort()
1425
+ writes.current.clear()
1426
+ }
1496
1427
  }, [])
1497
1428
  React.useEffect(() => {
1429
+ // A session switch abandons the previous session's read and writes: neither may
1430
+ // land on the new session's rows.
1498
1431
  request.current?.abort()
1499
1432
  request.current = null
1500
- applying.current = false
1501
- setCatalog([])
1502
- setManagement('unknown')
1503
- setSavedSettings(null)
1504
- setDrafts(emptyDrafts())
1433
+ for (const controller of writes.current) controller.abort()
1434
+ writes.current.clear()
1435
+ epoch.current += 1
1436
+ setRows([])
1437
+ setLoaded(false)
1438
+ setDefect(null)
1439
+ setReceipt(null)
1440
+ setWriting([])
1505
1441
  setPicked([])
1506
1442
  setBatchOpen(false)
1507
- setSelectedId(null)
1508
- setPane('list')
1509
- setFocusDimension(null)
1510
- setStatus(null)
1511
- setProofs(emptyProofs())
1512
- setPending(false)
1513
- setError(null)
1443
+ setQuery('')
1444
+ setFamily('all')
1445
+ setSource('all')
1446
+ setSort(null)
1514
1447
  }, [sessionId])
1515
1448
  const load = React.useCallback(async signal => {
1516
- setLoading(true)
1449
+ const mine = ++epoch.current
1517
1450
  try {
1518
1451
  const payload = await inspectSessionCatalog(sessionId, signal)
1519
- if (signal.aborted || !mounted.current || request.current?.signal !== signal) return
1520
- if (payload?.schema !== 'ptc-capability-session-catalog/v1' || !Array.isArray(payload.entries)) {
1521
- throw new Error('Invalid capability catalog')
1452
+ if (!mounted.current || signal.aborted || mine !== epoch.current) return
1453
+ // The only accepted response shape. `config` states a global switch, so it is
1454
+ // read to confirm the shape and never drawn: a switch cannot decide a cell.
1455
+ if (payload?.schema !== 'ptc-capability-session-catalog/v2' || !Array.isArray(payload.rows)
1456
+ || payload.config === null || typeof payload.config !== 'object') {
1457
+ throw new Error('Invalid capability catalogue')
1522
1458
  }
1523
- setCatalog(payload.entries)
1524
- setPicked(current => current.filter(id => payload.entries.some(entry => entry.id === id)))
1525
- setManagement(['managed', 'paused', 'off', 'unknown'].includes(payload.management)
1526
- ? payload.management : 'unknown')
1527
- setSavedSettings(payload.savedSettings !== null && typeof payload.savedSettings === 'object'
1528
- && !Array.isArray(payload.savedSettings) ? payload.savedSettings : null)
1529
- setError(null)
1530
- return payload.entries
1531
- } catch (failure) {
1532
- if (!signal.aborted && mounted.current && request.current?.signal === signal) setError(String(failure?.message ?? failure))
1533
- } finally {
1534
- if (!signal.aborted && mounted.current && request.current?.signal === signal) setLoading(false)
1459
+ setRows(payload.rows)
1460
+ setLoaded(true)
1461
+ setDefect(null)
1462
+ setPicked(current => current.filter(id => payload.rows.some(row => row.id === id)))
1463
+ } catch (cause) {
1464
+ if (mounted.current && !signal.aborted && mine === epoch.current) setDefect(String(cause?.message ?? cause))
1535
1465
  }
1536
1466
  }, [sessionId])
1537
1467
  React.useEffect(() => {
1538
- if (!open || !enabled || !available) {
1539
- if (!applying.current) { request.current?.abort(); request.current = null }
1540
- setLoading(false)
1468
+ if (!active) {
1469
+ request.current?.abort()
1470
+ request.current = null
1541
1471
  return
1542
1472
  }
1543
- // An in-flight apply owns the shared catalog request and reloads it on completion.
1544
- if (applying.current) return
1545
1473
  const controller = new AbortController()
1546
1474
  request.current = controller
1547
1475
  void load(controller.signal)
1548
- return () => { controller.abort(); if (request.current === controller) request.current = null }
1549
- }, [open, enabled, available, load])
1550
- const choices = drafts
1551
- const choiceFor = entry => choices[entry.id] ?? entry.temporary ?? 'inherit'
1552
- // Session writes also require the saved configuration to keep capability
1553
- // management and context on; a frozen managed session cannot be edited
1554
- // while the current settings have them off.
1555
- const savedBlocks = savedSettings !== null && (savedSettings.enabled === false || savedSettings.contextEnabled === false)
1556
- const writesBlocked = management === 'paused' || management === 'off' || savedBlocks
1557
- const sourceKey = entry => entry.source?.kind === 'mcp' ? 'mcp:' + (entry.source.server ?? '')
1558
- : entry.source?.kind === 'skill' ? 'skill:' + (entry.source.bucket ?? '')
1559
- : entry.source?.kind ?? 'unknown'
1560
- const sourceLabel = entry => entry.source?.kind === 'mcp' ? `MCP · ${entry.source.server ?? ''}`
1561
- : entry.source?.kind === 'skill' ? `Skill · ${entry.source.bucket
1562
- ? t(`capabilities.source.${entry.source.bucket}`) : t('capabilities.source.unknown')}`
1563
- : t(`capabilities.source.${entry.source?.kind === 'host' ? 'host' : 'unknown'}`)
1564
- const category = entry => entry.category === 'skill' ? 'skills'
1565
- : ['server', 'resource'].includes(entry.category) ? 'mcp'
1566
- : entry.source?.kind === 'mcp' ? 'mcp_tools' : 'tools'
1567
- const type = entry => entry.category === 'server' ? 'server'
1568
- : entry.source?.kind === 'mcp' ? 'resource'
1569
- : entry.category === 'skill' ? 'skill'
1570
- : entry.category === 'resource' ? 'resource'
1571
- : entry.category === 'tool' ? 'tool' : 'other'
1572
- const displayedSource = entry => entry.source?.kind === 'mcp' ? entry.source.server ?? sourceLabel(entry) : sourceLabel(entry)
1573
- const effectiveAvailability = entry => entry.effective === 'pending' ? 'pending'
1574
- : entry.enclosingClosure || entry.effective === 'closed' ? 'closed'
1575
- : entry.effectiveDimensions?.availability
1576
- ?? (['full', 'on-demand', 'host', 'enabled'].includes(entry.effective) ? 'enabled' : 'unknown')
1577
- const effectiveContext = entry => entry.effective === 'pending' ? 'pending'
1578
- : effectiveAvailability(entry) === 'closed' ? 'closed'
1579
- : entry.effectiveDimensions?.disclosure
1580
- ?? (['full', 'on-demand', 'host'].includes(entry.effective) ? entry.effective : 'unknown')
1581
- const sources = [...new Set(catalog.filter(entry => kind === 'all' || category(entry) === kind).map(sourceKey))].sort()
1582
- const setCompatibleKind = next => {
1583
- setKind(next)
1584
- if (sourceFilter !== 'all' && !catalog.some(entry => (next === 'all' || category(entry) === next)
1585
- && sourceKey(entry) === sourceFilter)) setSourceFilter('all')
1586
- }
1587
- const setCompatibleSource = next => {
1588
- setSourceFilter(next)
1589
- if (next !== 'all' && !catalog.some(entry => (kind === 'all' || category(entry) === kind)
1590
- && sourceKey(entry) === next)) setKind('all')
1591
- }
1592
- const filtered = catalog.filter(entry => {
1593
- const text = [entry.name, entry.id, entry.description, entry.source?.server, entry.source?.bucket].filter(Boolean).join(' ').toLocaleLowerCase()
1594
- return (kind === 'all' || category(entry) === kind)
1595
- && (sourceFilter === 'all' || sourceKey(entry) === sourceFilter)
1596
- && (stateFilter === 'all' || (stateFilter === 'changed' ? Object.hasOwn(choices, entry.id) : entry.effective === stateFilter))
1597
- && text.includes(query.trim().toLocaleLowerCase())
1598
- }).sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id))
1599
- const groups = ['tools', 'skills', 'mcp', 'mcp_tools']
1600
- const defaultOrder = groups.flatMap(group => filtered.filter(entry => category(entry) === group))
1601
- const matches = sortCapabilityEntries(defaultOrder, sort, (entry, column) => column === 'name' ? entry.name ?? entry.id
1602
- : column === 'type' ? t(`capabilities.type.${type(entry)}`)
1603
- : column === 'source' ? displayedSource(entry)
1604
- : column === 'availability' ? capabilityChoiceLabel(t, effectiveAvailability(entry))
1605
- : capabilityChoiceLabel(t, effectiveContext(entry)))
1606
- const selected = matches.find(entry => entry.id === selectedId)
1607
- ?? ['tools', 'skills', 'mcp', 'mcp_tools'].map(group => matches.find(entry => category(entry) === group)).find(Boolean)
1608
- const staged = Object.entries(choices).flatMap(([id, choice]) => typeof choice === 'object'
1609
- ? Object.entries(choice).map(([dimension, selection]) => [id, selection, dimension]) : [[id, choice]])
1610
- const pickedRows = picked.map(id => catalog.find(entry => entry.id === id)).filter(Boolean)
1611
- const allMatchesPicked = matches.length > 0 && matches.every(entry => picked.includes(entry.id))
1612
- const someMatchesPicked = matches.some(entry => picked.includes(entry.id))
1613
- const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
1614
- const toggleAllMatches = () => setPicked(current => allMatchesPicked
1615
- ? current.filter(id => !matches.some(entry => entry.id === id))
1616
- : [...new Set([...current, ...matches.map(entry => entry.id)])])
1617
- // One staging rule for single and batch edits: an item keeps one temporary
1618
- // patch, "inherit" removes the dimension and reselecting the stored choice
1619
- // never dirties the draft.
1620
- const stageDimension = (current, entry, dimension, choice) => {
1621
- const next = { ...current }
1622
- const patch = typeof next[entry.id] === 'object' && next[entry.id] !== null ? { ...next[entry.id] } : {}
1623
- const original = storedDimension(entry, dimension) ?? 'inherit'
1624
- const selectedChoice = patch[dimension] ?? original
1625
- if (choice !== selectedChoice) {
1626
- if (choice === original) delete patch[dimension]
1627
- else patch[dimension] = choice
1476
+ return () => {
1477
+ controller.abort()
1478
+ if (request.current === controller) request.current = null
1628
1479
  }
1629
- if (Object.keys(patch).length) next[entry.id] = patch; else delete next[entry.id]
1630
- return next
1631
- }
1632
- const chooseDimension = (id, dimension, choice) => {
1633
- const entry = catalog.find(item => item.id === id)
1634
- if (entry) setDrafts(current => stageDimension(current, entry, dimension, choice))
1635
- }
1636
- const choose = (id, next) => {
1637
- const entry = catalog.find(item => item.id === id)
1638
- if (!entry) return
1639
- const original = entry.temporary ?? 'inherit'
1640
- if (next === (choices[id] ?? original)) return
1641
- setDrafts(current => {
1642
- const nextChoices = { ...current }
1643
- if (next === original) delete nextChoices[id]
1644
- else nextChoices[id] = next
1645
- return nextChoices
1646
- })
1647
- }
1648
- const stageBatch = (ids, selections) => setDrafts(current => ids.reduce((draft, id) => {
1649
- const entry = catalog.find(item => item.id === id)
1650
- if (!entry) return draft
1651
- return ['availability', 'disclosure'].reduce((next, dimension) =>
1652
- selections[dimension] === undefined || selections[dimension] === 'keep'
1653
- ? next : stageDimension(next, entry, dimension, selections[dimension]), draft)
1654
- }, current))
1655
- const hasInheritanceReset = staged.some(([, selection]) => selection === 'inherit')
1656
- const apply = async targetScope => {
1657
- if (applying.current || !enabled || !available || writesBlocked || staged.length === 0) return
1658
- if (targetScope === 'global' && hasInheritanceReset) return
1659
- const submitted = structuredClone(choices)
1660
- applying.current = true
1661
- setPending(true)
1662
- setStatus(null)
1480
+ // The saved address is read again when the configuration under this page changes,
1481
+ // so a rule edit elsewhere cannot leave the previous value on screen.
1482
+ }, [active, load, revision])
1483
+ const reload = () => {
1663
1484
  const controller = new AbortController()
1664
1485
  request.current?.abort()
1665
1486
  request.current = controller
1666
- // One owner releases the shared request slot and the pending state on every exit path, so a
1667
- // closed, superseded or failed apply cannot strand the workbench in "applying".
1668
- const finish = () => {
1669
- if (request.current === controller) request.current = null
1670
- applying.current = false
1671
- if (mounted.current) setPending(false)
1672
- }
1487
+ void load(controller.signal)
1488
+ onReload?.()
1489
+ }
1490
+ /**
1491
+ * One intention, one record. The write itself is the caller's: a cell runs the
1492
+ * write its own address owns, the receipt that write returns is what the footer
1493
+ * shows, and the rows are re-read from the record rather than patched from the
1494
+ * answer. A command that is not listed for this session reports its own refusal;
1495
+ * it is never pre-empted.
1496
+ */
1497
+ const perform = async (id, run) => {
1498
+ const controller = new AbortController()
1499
+ writes.current.add(controller)
1500
+ setWriting(current => current.includes(id) ? current : [...current, id])
1501
+ setReceipt(null)
1673
1502
  try {
1674
- let completed = 0
1675
- let pendingCount = 0
1676
- let failure = null
1677
- let savedOnly = null
1678
- const observed = {}
1679
- for (const [id, selection, dimension] of staged) {
1680
- if (controller.signal.aborted) break
1681
- const line = `set ${targetScope} ${encodeURIComponent(id)} ${dimension ? dimension + ' ' : ''}${selection}`
1682
- try {
1683
- const result = await commands.run(sessionId, line, controller.signal)
1684
- const receipt = readCapabilityCommandReceipt(result.text)
1685
- if (!receipt || receipt.sessionId !== sessionId || !['recorded', 'pending'].includes(receipt.state)) {
1686
- throw new Error(receipt ? capabilityCommandText(t, result.text) : t('capabilities.quick.unknownReceipt'))
1687
- }
1688
- if (receipt?.state === 'pending') pendingCount += 1
1689
- // A receipt that names the capability carries the only proof this
1690
- // panel can hold: what the save did and did not establish.
1691
- if (receipt.proof !== undefined) observed[receipt.proof.id] = { proof: receipt.proof, command: `/capabilities ${line}` }
1692
- completed += 1
1693
- } catch (cause) {
1694
- failure = String(cause?.message ?? cause)
1695
- savedOnly = readCapabilityCommandReceipt(failure)?.state === 'saved-only'
1696
- ? readCapabilityCommandReceipt(failure) : null
1697
- break
1698
- }
1699
- }
1700
- if (mounted.current && Object.keys(observed).length) setProofs(current => ({ ...current, ...observed }))
1701
- if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1702
- const refreshed = await load(controller.signal)
1703
- if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1704
- const confirmed = refreshed ? staged.slice(0, completed).filter(([id, selection, dimension]) => {
1705
- const entry = refreshed.find(item => item.id === id)
1706
- if (dimension) return entry && (targetScope === 'session'
1707
- ? (entry.temporaryRule?.[dimension] ?? 'inherit') === selection
1708
- : (entry.savedRule?.[dimension] ?? 'automatic') === selection && entry.temporaryRule?.[dimension] === undefined)
1709
- return entry && (targetScope === 'session' ? entry.temporary === selection
1710
- : entry.saved === selection && entry.temporary === 'inherit')
1711
- }) : []
1712
- setDrafts(current => {
1713
- const nextChoices = { ...current }
1714
- for (const [id, selection, dimension] of confirmed) {
1715
- if (savedOnly && id === staged[completed]?.[0] && dimension === staged[completed]?.[2]) continue
1716
- if (dimension) {
1717
- if (nextChoices[id]?.[dimension] !== submitted[id]?.[dimension]) continue
1718
- const patch = { ...nextChoices[id] }; delete patch[dimension]
1719
- if (Object.keys(patch).length) nextChoices[id] = patch; else delete nextChoices[id]
1720
- } else if (nextChoices[id] === submitted[id]) delete nextChoices[id]
1721
- }
1722
- return nextChoices
1723
- })
1724
- if (savedOnly) setStatus({ key: 'capabilities.quick.savedOnly', params: { count: completed, error: savedOnly.error ?? '' } })
1725
- else if (failure || !refreshed || confirmed.length < completed) setStatus({ key: 'capabilities.quick.partial',
1726
- params: { count: confirmed.length, error: failure ?? t('capabilities.quick.confirmationUnavailable') } })
1727
- else setStatus({ key: pendingCount ? 'capabilities.quick.pendingApplied' : 'capabilities.quick.applied', params: { count: completed } })
1503
+ const answer = await run(controller.signal)
1504
+ if (!mounted.current || controller.signal.aborted) return
1505
+ setReceipt({ text: receiptText(t, answer) })
1506
+ await load(controller.signal)
1507
+ // The caller distinguishes a write that ran from a row it skipped as already matching.
1508
+ return { ok: true }
1509
+ } catch (cause) {
1510
+ const error = String(cause?.message ?? cause)
1511
+ if (mounted.current && !controller.signal.aborted) setReceipt({ error })
1512
+ // A batch runs one write at a time, so the next row's receipt would replace this
1513
+ // refusal; the caller gets it back to state the batch's failure after the loop.
1514
+ return { error }
1728
1515
  } finally {
1729
- finish()
1516
+ writes.current.delete(controller)
1517
+ if (mounted.current) setWriting(current => current.filter(item => item !== id))
1730
1518
  }
1731
1519
  }
1732
- const discard = () => { setDrafts(emptyDrafts()); setStatus(null) }
1733
- const close = () => {
1734
- // A confirmed apply keeps its own lifecycle: refusing the close keeps an accidental
1735
- // Escape or backdrop click from aborting host commands the user already submitted.
1736
- if (applying.current) return
1737
- request.current?.abort()
1738
- request.current = null
1739
- applying.current = false
1740
- setPending(false)
1741
- setDrafts(emptyDrafts())
1742
- setStatus(null)
1743
- onClose()
1520
+ const record = (channel, line, id) => perform(id, signal => channel.run(sessionId, line, signal))
1521
+ const word = value => t(`capabilities.word.${value}`)
1522
+ // A batch writes one column of one entrance, so it names that column's dimension with
1523
+ // the page's own word instead of the layer editor's rule name.
1524
+ const dimensionWord = dimension => t(`capabilities.dimension.${dimension}`)
1525
+ const choiceOptions = values => values.map(value => ({ value, label: word(value) }))
1526
+ const familyOf = row => row.kind === 'binding' ? 'bindings'
1527
+ : row.kind === 'server' ? 'mcp'
1528
+ : row.category === 'skill' ? 'skills'
1529
+ : row.category === 'resource' ? 'mcp'
1530
+ : row.source?.kind === 'mcp' ? 'mcp_tools' : 'tools'
1531
+ const badgeOf = row => row.kind === 'binding' ? 'binding'
1532
+ : row.kind === 'server' ? 'server'
1533
+ : row.category === 'skill' ? 'skill'
1534
+ : row.category === 'resource' || row.source?.kind === 'mcp' ? 'resource' : 'tool'
1535
+ // Two rows share one source filter entry exactly when they state the same source,
1536
+ // and the entry is named by the row that states it.
1537
+ const sourceKeyOf = row => row.kind === 'binding' ? 'bindings'
1538
+ : JSON.stringify(row.source ?? namespaceSource(row))
1539
+ const sourceText = row => row.kind === 'binding' ? t('capabilities.source.bindings')
1540
+ : capabilitySourceText(t, row)
1541
+ const sourceFilterLabel = row => {
1542
+ const servers = mcpServersOf(row.source)
1543
+ if (row.source?.kind === 'mcp' && servers.length > 0) return `MCP · ${servers.join(t('capabilities.source.separator'))}`
1544
+ // The option names the whole source its key identifies. A skill's bucket does not:
1545
+ // two providers can declare into one bucket, and two options reading one word would
1546
+ // offer a distinction this filter cannot state.
1547
+ return row.source?.kind === 'skill'
1548
+ ? `Skill · ${sourceText(row)}${row.source.provider ? ` · ${row.source.provider}` : ''}`
1549
+ : sourceText(row)
1744
1550
  }
1745
- const filter = (label, value, onChange, options, disabled = false) => h('div', { className: 'ptcPlusCapabilityFilter' },
1746
- h('span', null, label), h(Select, { label, value, onChange, disabled,
1747
- options: options.map(([key, text]) => ({ value: key, label: text })) }))
1748
- const ruleLabel = (rule, empty) => capabilityRuleLabel(t, rule, empty)
1749
- const statusLabel = value => value && typeof value === 'object' ? ruleLabel(value) : capabilityChoiceLabel(t, value)
1750
- // The stored temporary value for one dimension, including the legacy scalar
1751
- // form where a single selection covered both dimensions.
1752
- const storedDimension = (entry, dimension) => {
1753
- const rule = entry.temporaryRule !== null && typeof entry.temporaryRule === 'object' && !Array.isArray(entry.temporaryRule)
1754
- ? entry.temporaryRule : undefined
1755
- const override = rule?.[dimension]
1756
- if (override !== undefined) return override
1757
- if (entry.temporary === 'automatic') return 'automatic'
1758
- if (typeof entry.temporary === 'string' && !['inherit', 'automatic', 'unknown', 'pending'].includes(entry.temporary)) {
1759
- return dimension === 'availability' ? (entry.temporary === 'closed' ? 'closed' : 'enabled')
1760
- : (CAPABILITY_DISCLOSURES.includes(entry.temporary) ? entry.temporary : undefined)
1551
+ // Both addresses, stated from the read: the saved column is the configuration's
1552
+ // own value for this object and the session column is the record's answer, with
1553
+ // `inherit` when this session recorded nothing. A server has no disclosure of its
1554
+ // own, so both disclosure cells state where that decision lives instead of a
1555
+ // denial or another row's answer.
1556
+ // The saved cell states its own record's word: a dimension this record wrote nothing for reads
1557
+ // `automatic`, never a resolved value another layer would answer (capability-surface §8.1c).
1558
+ const savedOf = (row, dimension) => row.saved?.[dimension] ?? 'automatic'
1559
+ const savedEnabled = row => word(savedOf(row, 'availability'))
1560
+ const savedDisclosure = row => row.kind === 'server' ? t('capabilities.word.perMember')
1561
+ : word(savedOf(row, 'disclosure'))
1562
+ const recordedOf = (row, dimension) => row.session?.[dimension] ?? 'inherit'
1563
+ const recordWord = (row, dimension) => row.record?.[dimension]
1564
+ /** The layer whose record decided this dimension, or null when this object's own entry did. */
1565
+ const recordLayer = (row, dimension) => row.record?.layers?.[dimension] ?? null
1566
+ /** A recorded decision above this object: the row states the layer's word in its receipt. */
1567
+ const adoptionOf = row => ['use', 'skip', 'damaged'].includes(row.session?.use) ? row.session.use : 'use'
1568
+ /**
1569
+ * The receipt column: which record this row's decision leaves, and what it states
1570
+ * there — or that nothing has been written at that address yet. A missing record is
1571
+ * that address's own "unwritten" receipt, not a blank: deleting this column would
1572
+ * make "where does this decision get reconciled" unanswerable, which is why §4 asks
1573
+ * for it and why the evidence column left the page.
1574
+ */
1575
+ const receiptOf = row => {
1576
+ if (row.kind === 'binding') {
1577
+ const record = t('capabilities.word.bindingRecord')
1578
+ if (adoptionOf(row) === 'damaged') return `${record} · ${word('damaged')}`
1579
+ return row.session?.recorded === true ? `${record} · ${t(ADOPTION_WORDS[adoptionOf(row)])}`
1580
+ : `${record} · ${t('capabilities.word.unwritten')}`
1761
1581
  }
1762
- return undefined
1582
+ const record = t('capabilities.word.sessionRecord')
1583
+ // The receipt states the record's own text, not the resolution the value column
1584
+ // shows, and names the layer when a record above this object decided it. That layer's row is
1585
+ // part of this same read and states the word it wrote, so the receipt states that word:
1586
+ // `inherit` is this row's word for its own entry having written nothing (§4), never a word a
1587
+ // deciding record wrote.
1588
+ // A server row states every word its own rule holds, including a disclosure word its rule
1589
+ // can carry (§8.3): that word answers the members that wrote none at runtime, and a receipt
1590
+ // that said `未写下` while the record held it would deny what this same read carries. Its
1591
+ // disclosure cell still states the delegation, because the decision is made for the members.
1592
+ const statedDimensions = DIMENSIONS
1593
+ const layerWord = (layer, dimension) => rows
1594
+ .find(candidate => candidate.id === layer)?.record?.[dimension]
1595
+ const own = statedDimensions.filter(dimension => recordWord(row, dimension) !== undefined
1596
+ && recordLayer(row, dimension) === null)
1597
+ const byLayer = new Map()
1598
+ for (const dimension of statedDimensions) {
1599
+ const layer = recordLayer(row, dimension)
1600
+ // A dimension this object did not write is still answered by that layer at runtime, so the
1601
+ // receipt names it. A layer is named only for a dimension that layer's record wrote; a read
1602
+ // that does not carry the layer's row states the layer alone instead of inventing a word.
1603
+ if (layer === null) continue
1604
+ const stated = layerWord(layer, dimension)
1605
+ byLayer.set(layer, [...(byLayer.get(layer) ?? []), ...(stated === undefined ? [] : [`${dimension} ${stated}`])])
1606
+ }
1607
+ const parts = [
1608
+ ...(own.length === 0 ? [] : [own.map(dimension => `${dimension} ${recordWord(row, dimension)}`).join(' · ')]),
1609
+ ...[...byLayer].map(([layer, stated]) => stated.length === 0
1610
+ ? t('capabilities.receipt.layer', { layer })
1611
+ : `${t('capabilities.receipt.layer', { layer })} · ${stated.join(' · ')}`),
1612
+ ]
1613
+ return parts.length === 0 ? `${record} · ${t('capabilities.word.unwritten')}`
1614
+ : `${record} · ${parts.join(' · ')}`
1763
1615
  }
1764
- // The value a dimension falls back to when this session keeps no temporary
1765
- // change; the frozen plan already resolved the saved layers.
1766
- const frozenDimension = (entry, dimension) => {
1767
- const source = entry.sourceDimensions?.[dimension]
1768
- if (typeof source === 'string') return source
1769
- const effective = entry.effectiveDimensions?.[dimension]
1770
- if (typeof effective === 'string' && storedDimension(entry, dimension) === undefined) return effective
1771
- if (dimension === 'availability') return entry.effective === 'closed' ? 'closed'
1772
- : ['full', 'on-demand', 'host', 'enabled'].includes(entry.effective) ? 'enabled' : undefined
1773
- return ['full', 'on-demand', 'host'].includes(entry.effective) ? entry.effective : undefined
1616
+ // A control's own label names the address it writes, so a cell needs no neighbour
1617
+ // to be read (§3.2). The address is the object's identity, not its display name:
1618
+ // two rows can read alike, and only one of them is the record's key.
1619
+ const controlLabel = (column, row) => t(`capabilities.control.${column}`, { name: row.id })
1620
+ // The no-op test compares the record's own word, not the resolved value: a record
1621
+ // that wrote `automatic` and one that wrote the value it resolves to are different
1622
+ // records, and the second can only replace the first if the write is allowed.
1623
+ const writeSession = (row, dimension, value) => {
1624
+ if ((recordWord(row, dimension) ?? 'inherit') === value) return
1625
+ // The promise is the write: a batch awaits it so each command composes from the record
1626
+ // the previous one left, and nothing is fired in parallel.
1627
+ return record(commands, `set session ${encodeURIComponent(row.id)} ${dimension} ${value}`, row.id)
1774
1628
  }
1775
- const stateText = value => value === undefined ? t('capabilities.quick.unknown') : statusLabel(value)
1776
- const dimensionCurrent = (entry, dimension) => choices[entry.id]?.[dimension] ?? storedDimension(entry, dimension) ?? 'inherit'
1777
- const dimensionImpact = (entry, dimension) => {
1778
- const draft = choices[entry.id]?.[dimension]
1779
- const choice = draft ?? storedDimension(entry, dimension) ?? 'inherit'
1780
- const stagedChange = draft !== undefined
1781
- if (choice === 'inherit') return { state: frozenDimension(entry, dimension), origin: 'capabilities.quick.originPlan', staged: stagedChange }
1782
- if (choice === 'automatic') return { upper: true, staged: stagedChange }
1783
- return { state: choice, origin: 'capabilities.quick.originSession', staged: stagedChange }
1629
+ const writeSaved = (row, dimension, value) => {
1630
+ if (savedOf(row, dimension) === value) return
1631
+ if (typeof savedCommands?.run !== 'function') return
1632
+ return perform(row.id, async signal => {
1633
+ const written = await savedCommands.run({ id: row.id, dimension, selection: value }, signal)
1634
+ return { text: capabilityReceiptNotice(t, { state: 'recorded', record: { ...written, scope: 'global' } }, 'receipt') }
1635
+ })
1784
1636
  }
1785
- const impactLine = impact => impact.upper
1786
- ? t(impact.staged ? 'capabilities.quick.impactUpperPending' : 'capabilities.quick.impactUpper')
1787
- : t(impact.staged ? 'capabilities.quick.impactPending' : 'capabilities.quick.impact',
1788
- { state: stateText(impact.state), source: t(impact.origin) })
1789
- const dimensionOptions = (entry, dimension) => {
1790
- const restore = stateText(frozenDimension(entry, dimension))
1791
- const inherit = storedDimension(entry, dimension) === undefined
1792
- ? t('capabilities.quick.noChange', { state: restore })
1793
- : t('capabilities.quick.undoTemp', { state: restore })
1794
- // "Let the level above decide" only differs from "no change" while the
1795
- // session's frozen source still carries an explicit rule for the dimension.
1796
- const sourceDimension = entry.sourceRule?.[dimension]
1797
- const distinctRule = sourceDimension !== undefined && sourceDimension !== 'automatic'
1798
- return ['inherit', ...(distinctRule || dimensionCurrent(entry, dimension) === 'automatic' ? ['automatic'] : []),
1799
- ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
1800
- .map(choice => ({ value: choice, label: choice === 'inherit' ? inherit
1801
- : choice === 'automatic' ? t('capabilities.quick.upperRule')
1802
- : t(choice === 'enabled' ? 'capabilities.action.enable' : choice === 'closed' ? 'capabilities.action.close' : `capabilities.${choice}`) }))
1637
+ // A damaged record still gets a control: the click writes the record that replaces
1638
+ // it, which is the only way the row can leave that state.
1639
+ const writeAdoption = row => void record(bindingCommands,
1640
+ `${adoptionOf(row) === 'use' ? 'skip' : 'use'} ${row.id}`, row.id)
1641
+ // The session column's own word, as one stable token per state. The row and the cell
1642
+ // both state it, so availability is read from a stripe and a mark before the word is
1643
+ // read: `enabled` and `closed` are the two answers no reader should have to decode from
1644
+ // identical text, and `inherit` must not read as a decision this session made. What those
1645
+ // words resolve to at runtime stays off the page: a category or `mcpServers.<server>` rule
1646
+ // answers a member whose own entry wrote nothing, a closed server answers over a member's
1647
+ // own `enabled`, and a paused capability switch stops applying the saved document — none of
1648
+ // those resolutions is a word these two columns hold (capability-surface §8.1b/§8.1c), so
1649
+ // none of them becomes this row's colour or a second state token.
1650
+ const sessionStateOf = row => row.kind === 'binding'
1651
+ ? adoptionOf(row)
1652
+ : (recordedOf(row, 'availability') ?? 'inherit')
1653
+ const sessionAvailabilityCell = row => row.kind === 'binding'
1654
+ ? h('div', { className: 'ptcPlusSessionCapabilityAvailability', 'data-state': sessionStateOf(row) },
1655
+ h('span', { className: 'ptcPlusSessionStateMark', 'data-state': sessionStateOf(row), 'aria-hidden': true }),
1656
+ h('button', { type: 'button', className: 'ptcPlusBindingToggle', 'data-kind': adoptionOf(row),
1657
+ 'aria-pressed': adoptionOf(row) === 'use' ? 'true' : 'false',
1658
+ 'aria-label': controlLabel('sessionEnabled', row),
1659
+ 'aria-busy': writing.includes(row.id) ? 'true' : undefined,
1660
+ onClick: () => writeAdoption(row) }, t(ADOPTION_WORDS[adoptionOf(row)])))
1661
+ : h('div', { className: 'ptcPlusSessionCapabilityAvailability', 'data-state': sessionStateOf(row) },
1662
+ h('span', { className: 'ptcPlusSessionStateMark', 'data-state': sessionStateOf(row), 'aria-hidden': true }),
1663
+ h(Select, { label: controlLabel('sessionEnabled', row), value: recordedOf(row, 'availability'),
1664
+ options: choiceOptions(SESSION_AVAILABILITY),
1665
+ onChange: value => writeSession(row, 'availability', value) }))
1666
+ const sessionDisclosureCell = row => row.kind === 'binding'
1667
+ // A binding's adoption record holds no disclosure decision at all, so the cell
1668
+ // states that address's own word instead of borrowing the one a service row owns.
1669
+ ? h('span', { className: 'ptcPlusSessionCapabilityDisclosure', 'data-state': 'inherit' },
1670
+ t('capabilities.word.inherit'))
1671
+ : row.kind === 'server'
1672
+ // A server carries no disclosure of its own: its members do.
1673
+ ? h('span', { className: 'ptcPlusSessionCapabilityDisclosure', 'data-state': 'per-member' },
1674
+ t('capabilities.word.perMember'))
1675
+ : h('div', { className: 'ptcPlusSessionCapabilityDisclosure',
1676
+ 'data-state': recordedOf(row, 'disclosure') ?? 'inherit' },
1677
+ h(Select, { label: controlLabel('sessionDisclosure', row), value: recordedOf(row, 'disclosure'),
1678
+ options: choiceOptions(SESSION_DISCLOSURE),
1679
+ onChange: value => writeSession(row, 'disclosure', value) }))
1680
+ // A control exists exactly when this entrance fixes the address the cell writes: the settings
1681
+ // entrance writes the saved document, so every row it states gets the controls its own address
1682
+ // holds — both dimensions for a capability, availability alone for a server, whose saved cell
1683
+ // edits `mcpServers.<server>` in that same document. Read-only disables, never withdraws.
1684
+ const savedControl = row => settingsEntrance
1685
+ const savedDisabled = () => writable === false
1686
+ // The saved column states the word its own record wrote, and that word is what this column
1687
+ // makes readable: `enabled`/`closed` take the success and error colours and `automatic` the
1688
+ // business colour, so the entrance that fixes the saved document shows at a glance which
1689
+ // objects it switched off — the entrance that has no session column included. The colour
1690
+ // states this column's own word only; it does not claim the answer a row gets here, because
1691
+ // the saved entry is one layer of a resolution this page does not compute (§8.1b/§8.1c).
1692
+ // The saved disclosure cell states one of five disclosure words and carries no state token:
1693
+ // no rule colours it, and a token no selector or case observes states nothing.
1694
+ const savedState = row => ({ 'data-state': savedOf(row, 'availability') })
1695
+ const savedEnabledCell = row => savedControl(row)
1696
+ ? h('div', { className: 'ptcPlusSessionCapabilitySaved', ...savedState(row) },
1697
+ h(Select, { label: controlLabel('savedEnabled', row), value: savedOf(row, 'availability'),
1698
+ disabled: savedDisabled(), options: choiceOptions(SAVED_AVAILABILITY),
1699
+ onChange: value => writeSaved(row, 'availability', value) }))
1700
+ : h('span', { className: 'ptcPlusSessionCapabilitySaved', ...savedState(row) }, savedEnabled(row))
1701
+ const savedDisclosureCell = row => savedControl(row) && row.kind !== 'server'
1702
+ // A server carries no disclosure of its own, so its saved disclosure cell is the statement
1703
+ // that the members decide it; its availability cell is the rule edit below.
1704
+ ? h('div', { className: 'ptcPlusSessionCapabilitySavedDisclosure' },
1705
+ h(Select, { label: controlLabel('savedDisclosure', row), value: savedOf(row, 'disclosure'),
1706
+ disabled: savedDisabled(), options: choiceOptions(SAVED_DISCLOSURE),
1707
+ onChange: value => writeSaved(row, 'disclosure', value) }))
1708
+ : h('span', { className: 'ptcPlusSessionCapabilitySavedDisclosure' }, savedDisclosure(row))
1709
+ // Every cell states one clipped line and keeps its full text as the title, so no row grows
1710
+ // with the length of its words. A display name that differs from the id is the one remaining
1711
+ // difference between two rows: it adds the id line below the name.
1712
+ const identityName = row => h('span', { className: 'ptcPlusSessionCapabilityName',
1713
+ title: row.name ?? row.id }, row.name ?? row.id)
1714
+ const identityId = row => row.name && row.name !== row.id
1715
+ ? h('code', { title: row.id }, row.id) : null
1716
+ const identityCell = row => row.kind === 'binding'
1717
+ ? h('span', { className: 'ptcPlusCapabilityIdentity' },
1718
+ h('span', { className: 'ptcPlusCapabilityIdentityText' }, identityName(row), identityId(row)))
1719
+ : h('div', { className: 'ptcPlusCapabilityIdentity' },
1720
+ // Only a capability row takes part in a batch: a server row's saved cell edits the scope's
1721
+ // `mcpServers.<server>` rule on its own, and a binding row writes the bindings document.
1722
+ row.kind !== 'capability' ? null : h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: picked.includes(row.id),
1723
+ 'aria-label': t('capabilities.selectForBatch', { name: row.name ?? row.id }),
1724
+ disabled: writable === false,
1725
+ onChange: () => setPicked(current => current.includes(row.id)
1726
+ ? current.filter(id => id !== row.id) : [...current, row.id]) }),
1727
+ h('div', { className: 'ptcPlusCapabilityIdentityText' }, identityName(row), identityId(row)))
1728
+ const cellsOf = row => {
1729
+ const cells = {
1730
+ object: identityCell(row),
1731
+ type: h('span', { className: 'ptcPlusCapabilityBadge', 'data-kind': badgeOf(row) }, t(`capabilities.type.${badgeOf(row)}`)),
1732
+ source: h('span', { className: 'ptcPlusCapabilitySource', title: sourceText(row) }, sourceText(row)),
1733
+ savedEnabled: savedEnabledCell(row),
1734
+ savedDisclosure: savedDisclosureCell(row),
1735
+ sessionEnabled: sessionAvailabilityCell(row),
1736
+ sessionDisclosure: sessionDisclosureCell(row),
1737
+ receipt: h('span', { className: 'ptcPlusSessionCapabilityReceipt', title: receiptOf(row) }, receiptOf(row)),
1738
+ }
1739
+ return cells
1740
+ }
1741
+ const columns = COLUMNS[settingsEntrance ? 'settings' : 'session']
1742
+ // What the reader compares in a column is the word that column shows: a translated
1743
+ // badge, a source label, a record's own word. Sorting therefore reads this page's
1744
+ // display values instead of the fields they were derived from.
1745
+ const sortValue = (row, column) => column === 'object' ? (row.name ?? row.id)
1746
+ : column === 'type' ? t(`capabilities.type.${badgeOf(row)}`)
1747
+ : column === 'source' ? sourceText(row)
1748
+ : column === 'savedEnabled' ? savedEnabled(row)
1749
+ : column === 'savedDisclosure' ? savedDisclosure(row)
1750
+ : column === 'sessionEnabled' ? (row.kind === 'binding' ? t(ADOPTION_WORDS[adoptionOf(row)])
1751
+ : word(recordedOf(row, 'availability')))
1752
+ : column === 'sessionDisclosure' ? (row.kind === 'binding' ? t('capabilities.word.inherit')
1753
+ // A server states where the decision lives, not a disclosure of its own,
1754
+ // so that is the word this column shows and therefore the word it sorts by.
1755
+ : row.kind === 'server' ? t('capabilities.word.perMember')
1756
+ : word(recordedOf(row, 'disclosure')))
1757
+ : ''
1758
+ // The receipt column states a whole sentence per row, so it offers no ordering: a
1759
+ // reader sorting by the wording of a receipt would be sorting by prose.
1760
+ const sortable = column => column !== 'receipt'
1761
+ const renderRow = row => {
1762
+ const cells = cellsOf(row)
1763
+ return h('div', { key: row.id, role: 'row',
1764
+ className: `ptcPlusSessionCapabilityRow ptcPlusCapabilityItem ptcPlusSessionCapabilityRow-${columns.length}`,
1765
+ 'data-capability-id': row.id, 'data-kind': row.kind,
1766
+ 'data-session-state': sessionStateOf(row),
1767
+ ...(row.kind === 'binding' ? { 'data-binding-id': row.id, 'data-binding-state': adoptionOf(row) } : {}) },
1768
+ ...columns.map(column => h(React.Fragment, { key: column }, React.cloneElement(cells[column], { role: 'cell' }))))
1803
1769
  }
1804
- const legacyFrozenState = entry => {
1805
- const stored = typeof entry.temporary === 'string' && !['inherit', 'automatic', 'unknown', 'pending'].includes(entry.temporary)
1806
- if (stored) return ['full', 'on-demand', 'closed'].includes(entry.sessionSourceSelection) ? entry.sessionSourceSelection : undefined
1807
- return ['full', 'on-demand', 'closed', 'host'].includes(entry.effective) ? entry.effective : undefined
1770
+ const columnsRow = () => h('div', {
1771
+ className: `ptcPlusSessionCapabilityColumns ptcPlusCapabilityColumns ptcPlusSessionCapabilityColumns-${columns.length}`,
1772
+ role: 'row' }, ...columns.map(column => h('span', { key: column, role: 'columnheader',
1773
+ 'aria-sort': sortable(column) ? capabilitySortDirection(sort, column) : undefined },
1774
+ sortable(column)
1775
+ ? h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
1776
+ 'aria-label': t('capabilities.sortColumn', { column: t(COLUMN_COPY[column]) }),
1777
+ onClick: () => setSort(current => cycleCapabilitySort(current, column)) },
1778
+ t(COLUMN_COPY[column]),
1779
+ h('span', { className: 'ptcPlusCapabilitySortMark', 'aria-hidden': true },
1780
+ capabilitySortDirection(sort, column) === 'ascending' ? '↑'
1781
+ : capabilitySortDirection(sort, column) === 'descending' ? '↓' : '↕'))
1782
+ : t(COLUMN_COPY[column]))))
1783
+ const capabilities = rows.filter(row => row.kind !== 'binding')
1784
+ const matches = row => {
1785
+ const text = [row.name, row.id, sourceText(row)].filter(Boolean).join(' ').toLocaleLowerCase()
1786
+ return text.includes(query.trim().toLocaleLowerCase())
1808
1787
  }
1809
- const choiceCurrent = entry => choices[entry.id] ?? entry.temporary ?? 'inherit'
1810
- const choiceImpact = entry => {
1811
- const draft = choices[entry.id]
1812
- const choice = draft ?? entry.temporary ?? 'inherit'
1813
- const stagedChange = draft !== undefined
1814
- if (choice === 'inherit') return { state: legacyFrozenState(entry), origin: 'capabilities.quick.originPlan', staged: stagedChange }
1815
- if (choice === 'automatic') return { upper: true, staged: stagedChange }
1816
- return { state: choice, origin: 'capabilities.quick.originSession', staged: stagedChange }
1788
+ const listed = sortCapabilityEntries(capabilities.filter(row => (family === 'all' || familyOf(row) === family)
1789
+ && (source === 'all' || sourceKeyOf(row) === source) && matches(row)), sort, sortValue)
1790
+ const bindings = sortCapabilityEntries(rows.filter(row => row.kind === 'binding'), sort, sortValue)
1791
+ const sources = new Map()
1792
+ for (const row of capabilities) {
1793
+ if (family !== 'all' && familyOf(row) !== family) continue
1794
+ const key = sourceKeyOf(row)
1795
+ if (!sources.has(key)) sources.set(key, sourceFilterLabel(row))
1817
1796
  }
1818
- const choiceOptions = selected ? [
1819
- 'inherit', 'automatic',
1820
- ...(selected.category === 'server' ? [] : ['full', 'on-demand']), 'closed',
1821
- ].map(choice => ({ value: choice, label: choice === 'inherit'
1822
- ? t(selected.temporary !== undefined && selected.temporary !== 'inherit' ? 'capabilities.quick.undoChange' : 'capabilities.quick.keepUnchanged')
1823
- : choice === 'automatic' ? t('capabilities.quick.upperRule')
1824
- : t(`capabilities.${choice}`) })) : []
1825
- const dimensionFacts = dimensions => ['availability', 'disclosure'].map(dimension =>
1826
- `${t(`capabilities.policy.${dimension}`)}: ${statusLabel(dimensions[dimension])} (${dimensions[`${dimension}Source`] ?? t('capabilities.quick.unknown')})`).join(' · ')
1827
- // A dimension whose effective value comes from the session-only temporary
1828
- // choice is marked beside the frozen inheritance it replaces.
1829
- const temporaryDimension = (entry, dimension) => {
1830
- const rule = entry.temporaryRule !== null && typeof entry.temporaryRule === 'object' && !Array.isArray(entry.temporaryRule)
1831
- ? entry.temporaryRule : undefined
1832
- const override = rule?.[dimension]
1833
- if (override !== undefined && override !== 'automatic') return override
1834
- if (typeof entry.temporary === 'string' && !['inherit', 'automatic'].includes(entry.temporary)) {
1835
- return dimension === 'availability' ? (entry.temporary === 'closed' ? 'closed' : 'enabled') : entry.temporary
1797
+ // A batch writes only a dimension this entrance shows a control for. The settings entrance
1798
+ // fixes saved addresses and reads no session record, so what a session recorded decides
1799
+ // nothing there; the session entrance writes the session address, and every capability row
1800
+ // now carries both of its controls — a member's cell states its own record, so writing it
1801
+ // always changes what the cell says.
1802
+ const batchWritable = row => DIMENSIONS.filter(dimension => settingsEntrance
1803
+ ? savedControl(row) && (dimension === 'availability' || row.kind === 'capability')
1804
+ : dimension === 'availability' || row.kind === 'capability')
1805
+ const pickedRows = capabilities.filter(row => picked.includes(row.id) && batchWritable(row).length > 0)
1806
+ const batchLines = () => pickedRows.flatMap(row => batchWritable(row)
1807
+ .filter(dimension => batchChoices[dimension] !== 'keep')
1808
+ .map(dimension => ({ row, dimension, choice: batchChoices[dimension] })))
1809
+ // A batch is the same decision applied to existing rows: it writes one record per
1810
+ // selected row through the same channel and creates no rows of its own.
1811
+ const applyBatch = async () => {
1812
+ const lines = batchLines()
1813
+ setBatchOpen(false)
1814
+ // Every dimension kept its current setting, so this batch writes nothing. The
1815
+ // click still owes the reader one sentence, and it is the footer's own receipt
1816
+ // expression that states it: a zero-write batch names no record, so it states the
1817
+ // batch's outcome instead of widening the command receipt vocabulary.
1818
+ if (lines.length === 0) {
1819
+ setReceipt({ text: { key: 'capabilities.batch.kept', params: {} } })
1820
+ return
1836
1821
  }
1837
- return undefined
1822
+ // One write at a time: each command composes the whole record from the log it reads, so
1823
+ // overlapping commands would drop each other's decision. A refusal does not stop the
1824
+ // remaining rows, and the batch states the first refusal after them instead of letting the
1825
+ // next row's success sentence replace it.
1826
+ let refusal, wrote = 0
1827
+ for (const { row, dimension, choice } of lines) {
1828
+ try {
1829
+ const outcome = settingsEntrance
1830
+ ? await writeSaved(row, dimension, choice)
1831
+ : await writeSession(row, dimension, choice)
1832
+ if (outcome?.error !== undefined) refusal ??= outcome.error
1833
+ else if (outcome?.ok === true) wrote += 1
1834
+ } catch (error) { refusal ??= String(error?.message ?? error) }
1835
+ }
1836
+ if (refusal !== undefined) setReceipt({ error: refusal })
1837
+ // Every picked word already matched its record, so no command ran; the click still owes the
1838
+ // reader one sentence rather than leaving the previous row's receipt standing.
1839
+ else if (wrote === 0) setReceipt({ text: { key: 'capabilities.batch.kept', params: {} } })
1838
1840
  }
1839
- const dimensionCell = (entry, dimension, value) => temporaryDimension(entry, dimension) === value
1840
- ? t('capabilities.quick.temporaryState', { state: statusLabel(value) }) : statusLabel(value)
1841
- const openEditor = (entry, dimension = 'availability') => { setSelectedId(entry.id); setPane('detail'); setFocusDimension(dimension) }
1842
- const renderRow = entry => h('div', { key: entry.id, role: 'row',
1843
- className: 'ptcPlusSessionCapabilityRow ptcPlusCapabilityItem', 'data-capability-id': entry.id, 'data-selected': selected?.id === entry.id,
1844
- onClick: () => openEditor(entry) },
1845
- h('div', { role: 'cell', className: 'ptcPlusCapabilityIdentity' },
1846
- h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: picked.includes(entry.id), disabled: writesBlocked,
1847
- 'aria-label': t('capabilities.selectForBatch', { name: entry.name ?? entry.id }),
1848
- onClick: event => event.stopPropagation(), onChange: () => togglePick(entry.id) }),
1849
- h('div', { className: 'ptcPlusCapabilityIdentityText' },
1850
- h('button', { type: 'button', className: 'ptcPlusSessionCapabilityName ptcPlusCapabilityName',
1851
- 'aria-current': selected?.id === entry.id ? 'true' : undefined,
1852
- onClick: event => { event.stopPropagation(); openEditor(entry) } }, entry.name ?? entry.id),
1853
- entry.name && entry.name !== entry.id ? h('code', null, entry.id) : null)),
1854
- h('span', { role: 'cell', className: 'ptcPlusCapabilityBadge', 'data-kind': type(entry) }, t(`capabilities.type.${type(entry)}`)),
1855
- h('span', { role: 'cell', className: 'ptcPlusCapabilitySource' }, displayedSource(entry)),
1856
- h('div', { role: 'cell', className: 'ptcPlusCapabilityAvailability', 'data-state': effectiveAvailability(entry) },
1857
- h('button', { type: 'button', className: 'ptcPlusCapabilityContext',
1858
- 'aria-label': t('capabilities.policy.availabilityFor', { name: entry.id }),
1859
- title: t('capabilities.quick.effective'),
1860
- onClick: event => { event.stopPropagation(); openEditor(entry, 'availability') } }, dimensionCell(entry, 'availability', effectiveAvailability(entry)))),
1861
- h('div', { role: 'cell', className: 'ptcPlusCapabilityContextCell' },
1862
- h('button', { type: 'button', className: 'ptcPlusCapabilityContext',
1863
- 'aria-label': t('capabilities.editContextFor', { name: entry.id }),
1864
- title: t('capabilities.quick.effective'),
1865
- onClick: event => { event.stopPropagation(); openEditor(entry, 'disclosure') } }, dimensionCell(entry, 'disclosure', effectiveContext(entry)))),
1866
- h('span', { role: 'cell', className: 'ptcPlusSessionCapabilityPending', 'data-changed': Object.hasOwn(choices, entry.id),
1867
- 'data-label': t('capabilities.quick.changed') },
1868
- Object.hasOwn(choices, entry.id) ? statusLabel(choices[entry.id]) : '—'))
1869
- return h(Modal, { open, onClose: close, title: t('capabilities.quick.modalTitle'),
1870
- closeLabel: t('settings.close'), headless: true, className: 'ptcPlusSessionCapabilityModal' },
1871
- h('div', { className: 'ptcPlusSessionCapabilityHead' },
1872
- h('div', null, h('h2', null, t('capabilities.quick.modalTitle')),
1873
- h('p', null, t('capabilities.quick.sessionIdentity', { session: sessionId }))),
1874
- h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('settings.close'), disabled: pending, onClick: close }, '×')),
1875
- h('div', { className: 'ptcPlusSessionCapabilityToolbar' },
1876
- h('label', { className: 'ptcPlusSearch' },
1877
- h('svg', { viewBox: '0 0 24 24', width: 16, height: 16, 'aria-hidden': true },
1878
- h('circle', { cx: 10, cy: 10, r: 6, fill: 'none', stroke: 'currentColor', strokeWidth: 2 }),
1879
- h('path', { d: 'm15 15 6 6', fill: 'none', stroke: 'currentColor', strokeWidth: 2 })),
1880
- h('input', { type: 'search', value: query, 'aria-label': t('capabilities.search'),
1881
- placeholder: t('capabilities.search'), onChange: event => setQuery(event.target.value) })),
1882
- filter(t('capabilities.quick.kind'), kind, setCompatibleKind,
1883
- ['all', 'tools', 'skills', 'mcp', 'mcp_tools'].map(key => [key, t(`capabilities.quick.${key}`)])),
1884
- filter(t('capabilities.sourceFilter'), sourceFilter, setCompatibleSource,
1885
- [['all', t('capabilities.allSources')], ...sources.map(key => [key,
1886
- sourceLabel(catalog.find(entry => sourceKey(entry) === key))])]),
1887
- filter(t('capabilities.quick.state'), stateFilter, setStateFilter,
1888
- [['all', t('capabilities.quick.allStates')], ['changed', t('capabilities.quick.changed')],
1889
- ...['full', 'on-demand', 'closed', 'pending'].map(key => [key, statusLabel(key)])])),
1890
- // A blocked workbench states why writes are off and how to restore them; the banner styling
1891
- // keeps that condition from reading as an incidental list message.
1892
- writesBlocked ? h('p', { className: 'ptcPlusCapabilityNotice ptcPlusCapabilityBlocked', role: 'status' },
1893
- h('svg', { className: 'ptcPlusCapabilityBlockedIcon', viewBox: '0 0 16 16', width: 14, height: 14,
1894
- 'aria-hidden': true, fill: 'none' },
1895
- h('path', { d: 'M8 2.3 14.7 13.5H1.3L8 2.3Z', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round' }),
1896
- h('path', { d: 'M8 6.4v3M8 11.2v.7', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' })),
1897
- t(management === 'paused' ? 'capabilities.quick.sessionPaused'
1898
- : management === 'off' ? 'capabilities.quick.sessionManagementOff'
1899
- : savedSettings?.contextEnabled === false ? 'capabilities.quick.savedPaused'
1900
- : 'capabilities.quick.savedManagementOff')) : null,
1901
- h('div', { className: 'ptcPlusSessionCapabilityWorkspace', 'data-pane': pane },
1902
- h('div', { className: 'ptcPlusSessionCapabilityList', role: 'table', 'aria-label': t('capabilities.quick.members'), ref: list },
1903
- h('div', { className: 'ptcPlusSessionCapabilityColumns ptcPlusCapabilityColumns', role: 'row' },
1904
- ...CAPABILITY_SORT_COLUMNS.map(column => h('span', { key: column.key, role: 'columnheader',
1905
- 'aria-sort': capabilitySortDirection(sort, column.key),
1906
- ...(column.key === 'name' ? { 'aria-label': t('capabilities.member'), className: 'ptcPlusCapabilityPickHead' } : {}) },
1907
- ...(column.key === 'name' ? [h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: allMatchesPicked,
1908
- ref: node => { if (node) node.indeterminate = someMatchesPicked && !allMatchesPicked },
1909
- disabled: writesBlocked || matches.length === 0, 'aria-label': t('capabilities.selectAllVisible'),
1910
- onChange: toggleAllMatches })] : []),
1911
- h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
1912
- onClick: () => setSort(current => cycleCapabilitySort(current, column.key)) },
1913
- t(`capabilities.${column.label}`), h('span', { 'aria-hidden': true },
1914
- capabilitySortDirection(sort, column.key) === 'ascending' ? '↑' : capabilitySortDirection(sort, column.key) === 'descending' ? '↓' : '↕')))),
1915
- h('span', { role: 'columnheader' }, t('capabilities.quick.changed'))),
1916
- loading ? h('p', { role: 'status' }, t('state.syncing')) : null,
1917
- error ? h('p', { role: 'alert' }, t('capabilities.quick.catalogError', { error })) : null,
1918
- !loading && !error && matches.length === 0 ? h('p', null, t('capabilities.noMatches')) : null,
1919
- ...(sort ? [h('div', { key: 'sorted', role: 'rowgroup' }, ...matches.map(renderRow))]
1920
- : groups.filter(group => matches.some(entry => category(entry) === group))
1921
- .map(group => h('section', { key: group, className: 'ptcPlusSessionCapabilityGroup' },
1922
- h('h3', null, t(`capabilities.quick.${group}`)),
1923
- h('div', { role: 'rowgroup' }, ...matches.filter(entry => category(entry) === group).map(renderRow)))))),
1924
- h('aside', { className: 'ptcPlusSessionCapabilityDetail', 'aria-label': t('capabilities.details'), ref: detail },
1925
- h('button', { type: 'button', className: 'ptcPlusSessionCapabilityBack', onClick: () => { setPane('list'); setFocusDimension('list') } },
1926
- t('capabilities.quick.backToList')),
1927
- selected ? h(React.Fragment, null,
1928
- h('h3', null, selected.name ?? selected.id), h('code', null, selected.id),
1929
- selected.description ? h('p', null, selected.description) : null,
1930
- h('dl', null,
1931
- h('dt', null, t('capabilities.sourceFilter')), h('dd', null, sourceLabel(selected)),
1932
- h('dt', null, t('capabilities.quick.globalSaved')), h('dd', null, selected.savedRule ? ruleLabel(selected.savedRule) : statusLabel(selected.saved)),
1933
- h('dt', null, t('capabilities.quick.sessionSource')), h('dd', null, selected.sourceRule ? ruleLabel(selected.sourceRule) : statusLabel(selected.sessionSourceSelection ?? 'unknown')),
1934
- h('dt', null, t('capabilities.quick.temporary')), h('dd', null, selected.temporaryRule ? ruleLabel(selected.temporaryRule, 'capabilities.quick.noOverride') : statusLabel(selected.temporary)),
1935
- h('dt', null, t('capabilities.quick.effective')), h('dd', null, statusLabel(selected.effective)),
1936
- selected.sourceDimensions ? h(React.Fragment, null, h('dt', null, t('capabilities.policy.frozenSource')), h('dd', null, dimensionFacts(selected.sourceDimensions))) : null,
1937
- selected.effectiveDimensions ? h(React.Fragment, null, h('dt', null, t('capabilities.policy.effectiveDimensions')), h('dd', null, dimensionFacts(selected.effectiveDimensions))) : null),
1938
- h('div', { className: 'ptcPlusCapabilityProof', 'data-capability-proof': selected.id },
1939
- h('h4', null, t('capabilities.proof.title')),
1940
- proofs[selected.id]
1941
- ? h(React.Fragment, null,
1942
- h('p', { className: 'ptcPlusDetail', role: 'status' }, capabilityProofCopy(t, proofs[selected.id].proof)),
1943
- h('small', null, t('capabilities.proof.observed', { command: proofs[selected.id].command })))
1944
- : h('p', { className: 'ptcPlusDetail' }, t('capabilities.proof.unobserved'))),
1945
- selected.source?.kind === 'mcp' && selected.category !== 'server'
1946
- ? (() => {
1947
- const server = catalog.find(entry => entry.category === 'server' && entry.source?.server === selected.source.server)
1948
- if (!server) return null
1949
- const proposed = choiceFor(server)
1950
- const availability = server.savedRule ? choices[server.id]?.availability ?? server.temporaryRule?.availability ?? 'inherit' : proposed
1951
- const sourceAvailability = server.sourceRule !== undefined ? server.sourceRule.availability : server.sessionSourceSelection
1952
- const pendingClose = server.managementEnabled !== false && (availability === 'inherit' ? sourceAvailability : availability) === 'closed'
1953
- const currentlyClosed = server.enclosingClosure ?? server.effective === 'closed'
1954
- const key = currentlyClosed ? 'capabilities.quick.disabledByServer'
1955
- : pendingClose ? 'capabilities.quick.pendingServerClose' : null
1956
- return key ? h('p', { className: 'ptcPlusCapabilityNotice' }, t(key, { server: selected.source.server })) : null
1957
- })() : null,
1958
- selected.category === 'server' ? h('div', { className: 'ptcPlusSessionCapabilityChildren' },
1959
- h('h4', null, t('capabilities.quick.serverTools')),
1960
- h('ul', null, ...catalog.filter(entry => entry.id !== selected.id
1961
- && entry.source?.kind === 'mcp' && entry.source.server === selected.source?.server)
1962
- .map(entry => h('li', { key: entry.id }, entry.name ?? entry.id)))) : null,
1963
- selected.savedRule ? h(React.Fragment, null, ...['availability', 'disclosure'].map(dimension => h(React.Fragment, { key: dimension },
1964
- h('div', { className: 'ptcPlusCapabilityFilter', 'data-capability-dimension': dimension },
1965
- h('span', null, t(`capabilities.policy.${dimension}`)),
1966
- h(Select, { label: t(`capabilities.policy.${dimension}`), disabled: !enabled || !available || pending || writesBlocked,
1967
- value: dimensionCurrent(selected, dimension),
1968
- onChange: next => chooseDimension(selected.id, dimension, next),
1969
- options: dimensionOptions(selected, dimension) })),
1970
- h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': dimension },
1971
- impactLine(dimensionImpact(selected, dimension))))))
1972
- : h(React.Fragment, null,
1973
- h('div', { className: 'ptcPlusCapabilityFilter' },
1974
- h('span', null, t('capabilities.quick.choice')),
1975
- h(Select, { label: t('capabilities.quick.choice'), value: choiceCurrent(selected),
1976
- disabled: !enabled || !available || pending || writesBlocked, options: choiceOptions,
1977
- onChange: next => choose(selected.id, next) })),
1978
- h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': 'selection' },
1979
- impactLine(choiceImpact(selected)))),
1980
- (typeof choices[selected.id] === 'string' ? choices[selected.id] === 'inherit'
1981
- : Object.values(choices[selected.id] ?? {}).includes('inherit')) ? h('p', { className: 'ptcPlusDetail' },
1982
- t('capabilities.quick.inheritSessionOnly')) : null) : h('p', null, t('capabilities.noMatches')))),
1983
- batchOpen ? h(Modal, { open: true, onClose: () => setBatchOpen(false), title: t('capabilities.batchSettings'),
1984
- closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
1985
- h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
1986
- h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('capabilities.batch.cancel'),
1987
- onClick: () => setBatchOpen(false) }, '\u00d7')),
1988
- h('div', { className: 'ptcPlusCapabilityBatchBody' },
1989
- h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.selected', { count: pickedRows.length })),
1990
- h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
1991
- ...pickedRows.map(entry => h('li', { key: entry.id }, h('span', null, entry.name ?? entry.id), h('code', null, entry.id)))),
1992
- h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
1993
- h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
1994
- h(Select, { value: batchChoices[dimension], label: t(`capabilities.batch.${dimension}`),
1995
- onChange: choice => setBatchChoices(current => ({ ...current, [dimension]: choice })),
1996
- options: ['keep', 'inherit', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
1997
- .map(choice => ({ value: choice, label: choice === 'keep' ? t('capabilities.batch.keep')
1998
- : choice === 'inherit' ? t('capabilities.batch.sessionInherit')
1999
- : choice === 'enabled' ? t('capabilities.action.enable')
2000
- : choice === 'closed' ? t('capabilities.action.close') : t(`capabilities.${choice}`) })) })))),
2001
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sessionStage'))),
2002
- h('div', { className: 'ptcPlusCapabilityBatchFooter' },
2003
- h('button', { type: 'button', className: 'ptcPlusButton', onClick: () => setBatchOpen(false) }, t('capabilities.batch.cancel')),
2004
- h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
2005
- disabled: pickedRows.length === 0 || !['availability', 'disclosure'].some(dimension => batchChoices[dimension] !== 'keep'),
2006
- onClick: () => { stageBatch(pickedRows.map(entry => entry.id), batchChoices); setBatchOpen(false) } },
2007
- t('capabilities.batch.stage', { count: pickedRows.length })))) : null,
2008
- h('div', { className: 'ptcPlusSessionCapabilityFooter' },
2009
- h('span', { role: 'status' }, pending ? t('capabilities.quick.applying') : status ? t(status.key, status.params)
2010
- : t('capabilities.quick.changes', { count: staged.length })),
2011
- h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending || staged.length === 0, onClick: discard },
2012
- t('capabilities.quick.discard')),
2013
- // Session-scoped temporary application stays the footer's primary action; saving global
2014
- // defaults is an explicit secondary choice, so no default-looking control changes global state.
2015
- h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary', title: t('capabilities.quick.sessionHint'),
2016
- disabled: pending || !enabled || !available || writesBlocked || staged.length === 0, onClick: () => void apply('session') },
2017
- t('capabilities.quick.applySession')),
2018
- h('button', { type: 'button', className: 'ptcPlusButton',
2019
- title: t(hasInheritanceReset ? 'capabilities.quick.inheritSessionOnly' : 'capabilities.quick.globalHint'),
2020
- disabled: pending || !enabled || !available || writesBlocked || staged.length === 0 || hasInheritanceReset,
2021
- onClick: () => void apply('global') },
2022
- t('capabilities.quick.applyGlobal')),
2023
- h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
2024
- 'aria-label': t('capabilities.batchSettings'),
2025
- disabled: pending || writesBlocked || picked.length === 0,
2026
- onClick: () => { setBatchChoices({ availability: 'keep', disclosure: 'keep' }); setBatchOpen(true) } },
2027
- t('capabilities.batchSettings'),
2028
- picked.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
2029
- title: t('capabilities.selectionCount', { count: picked.length }) },
2030
- String(picked.length)) : null)))
1841
+ const filter = (label, value, onChange, options) => h('div', { className: 'ptcPlusCapabilityFilter' },
1842
+ h('span', null, label), h(Select, { label, value, onChange,
1843
+ options: options.map(([key, text]) => ({ value: key, label: text })) }))
1844
+ // The two filters narrow the same list, so a value that no longer matches the
1845
+ // other dimension is withdrawn instead of silently emptying the table.
1846
+ const chooseFamily = next => {
1847
+ setFamily(next)
1848
+ if (source !== 'all'
1849
+ && !capabilities.some(row => (next === 'all' || familyOf(row) === next) && sourceKeyOf(row) === source)) setSource('all')
1850
+ }
1851
+ const chooseSource = next => {
1852
+ setSource(next)
1853
+ if (next !== 'all'
1854
+ && !capabilities.some(row => (family === 'all' || familyOf(row) === family) && sourceKeyOf(row) === next)) setFamily('all')
1855
+ }
1856
+ const close = () => {
1857
+ request.current?.abort()
1858
+ request.current = null
1859
+ onClose?.()
1860
+ }
1861
+ // §3.3: a page reports its own address set, so a missing receipt is read against the
1862
+ // record it belongs to instead of against an unnamed blank.
1863
+ const addressSet = settingsEntrance ? t('capabilities.surface.addresses.settings')
1864
+ : t('capabilities.surface.addresses.session', { session: sessionId })
1865
+ const table = h('div', { role: 'table',
1866
+ 'aria-label': t(settingsEntrance ? 'capabilities.surface.settingsTitle' : 'capabilities.quick.members') },
1867
+ columnsRow(),
1868
+ loaded && listed.length === 0 ? h('p', { className: 'ptcPlusSessionCapabilityMessage' },
1869
+ t(capabilities.length === 0 ? 'capabilities.quick.supplyEmpty' : 'capabilities.noMatches')) : null,
1870
+ ...listed.map(renderRow))
1871
+ const body = [
1872
+ h('div', { key: 'head', className: 'ptcPlusSessionCapabilityHead' },
1873
+ h('div', null,
1874
+ h('h2', null, t(settingsEntrance ? 'capabilities.surface.settingsTitle' : 'capabilities.quick.modalTitle')),
1875
+ h('p', { className: 'ptcPlusSessionCapabilityAddresses' }, addressSet)),
1876
+ inline === true ? null : h('button', { type: 'button', className: 'ptcPlusDialogClose',
1877
+ 'aria-label': t('settings.close'), onClick: close }, '\u00d7')),
1878
+ h('div', { key: 'toolbar', className: 'ptcPlusSessionCapabilityToolbar' },
1879
+ h('label', { className: 'ptcPlusSearch' },
1880
+ h('svg', { viewBox: '0 0 24 24', width: 16, height: 16, 'aria-hidden': true },
1881
+ h('circle', { cx: 10, cy: 10, r: 6, fill: 'none', stroke: 'currentColor', strokeWidth: 2 }),
1882
+ h('path', { d: 'm15 15 6 6', fill: 'none', stroke: 'currentColor', strokeWidth: 2 })),
1883
+ h('input', { type: 'search', value: query, 'aria-label': t('capabilities.search'),
1884
+ placeholder: t('capabilities.search'), onChange: event => setQuery(event.target.value) })),
1885
+ filter(t('capabilities.quick.kind'), family, chooseFamily,
1886
+ [['all', t('capabilities.quick.all')], ...FAMILIES.map(key => [key, t(`capabilities.quick.${key}`)])]),
1887
+ filter(t('capabilities.sourceFilter'), source, chooseSource,
1888
+ [['all', t('capabilities.allSources')], ...[...sources].sort(([a], [b]) => compare(a, b))]),
1889
+ h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
1890
+ 'aria-label': t('capabilities.batchSettings'), disabled: writable === false,
1891
+ onClick: () => { setBatchChoices({ availability: 'keep', disclosure: 'keep' }); setBatchOpen(true) } },
1892
+ t('capabilities.batchSettings'),
1893
+ picked.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true },
1894
+ String(picked.length)) : null)),
1895
+ // A read that failed is a defect in the panel's own source, so it names the error
1896
+ // and offers the retry beside the rows it could not replace.
1897
+ defect === null ? null : h('p', { key: 'defect', className: 'ptcPlusSessionCapabilityMessage', role: 'status' },
1898
+ t('capabilities.quick.catalogError', { error: defect }), ' ',
1899
+ h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: reload }, t('capabilities.reloadCatalog'))),
1900
+ h('div', { key: 'list', className: 'ptcPlusSessionCapabilityList' },
1901
+ h('div', { className: 'ptcPlusSessionCapabilityTable' }, table),
1902
+ // The group is present whenever this entrance states the binding document: the
1903
+ // Host supplies it whether or not this session has used any of it, and a
1904
+ // zero-row group is an empty table.
1905
+ settingsEntrance ? null
1906
+ : h('section', { key: 'bindings', className: 'ptcPlusSessionCapabilityGroup ptcPlusBindingGroup' },
1907
+ h('h3', null, t('bindings.title')),
1908
+ h('div', { className: 'ptcPlusBindingTable', role: 'table', 'aria-label': t('bindings.title') },
1909
+ columnsRow(),
1910
+ ...bindings.map(renderRow)))),
1911
+ batchOpen ? h(Modal, { key: 'batch', open: true, onClose: () => setBatchOpen(false),
1912
+ title: t('capabilities.batchSettings'), closeLabel: t('capabilities.batch.cancel'), headless: true,
1913
+ className: 'ptcPlusCapabilityBatchModal' },
1914
+ h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
1915
+ h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('capabilities.batch.cancel'),
1916
+ onClick: () => setBatchOpen(false) }, '\u00d7')),
1917
+ h('div', { className: 'ptcPlusCapabilityBatchBody' },
1918
+ h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.selected', { count: pickedRows.length })),
1919
+ h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
1920
+ ...pickedRows.map(row => h('li', { key: row.id }, h('span', null, row.name ?? row.id), h('code', null, row.id)))),
1921
+ h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...DIMENSIONS.map(dimension => {
1922
+ const address = t(settingsEntrance ? 'capabilities.batch.address.saved' : 'capabilities.batch.address.session')
1923
+ const label = `${address} · ${dimensionWord(dimension)}`
1924
+ return h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, label),
1925
+ h(Select, { value: batchChoices[dimension], label,
1926
+ onChange: choice => setBatchChoices(current => ({ ...current, [dimension]: choice })),
1927
+ // Each entrance offers the words its own address can hold: the session batch offers
1928
+ // the record's words plus `inherit`, the saved batch offers `automatic`.
1929
+ options: [{ value: 'keep', label: t('capabilities.batch.keep') },
1930
+ ...choiceOptions(dimension === 'availability'
1931
+ ? (settingsEntrance ? SAVED_AVAILABILITY : SESSION_AVAILABILITY)
1932
+ : (settingsEntrance ? SAVED_DISCLOSURE : SESSION_DISCLOSURE))] }))
1933
+ })),
1934
+ h('p', { className: 'ptcPlusDetail' },
1935
+ t(settingsEntrance ? 'capabilities.batch.effect' : 'capabilities.batch.effectSession'))),
1936
+ h('div', { className: 'ptcPlusCapabilityBatchFooter' },
1937
+ h('button', { type: 'button', className: 'ptcPlusButton', onClick: () => setBatchOpen(false) },
1938
+ t('capabilities.batch.cancel')),
1939
+ h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
1940
+ disabled: writable === false, onClick: () => void applyBatch() },
1941
+ t('capabilities.batch.record', { count: batchLines().length })))) : null,
1942
+ h('div', { key: 'footer', className: 'ptcPlusSessionCapabilityFooter' },
1943
+ // The footer states the same receipt the chat card does, in the current
1944
+ // language: which record this write named, its choice and its session. The raw
1945
+ // payload stays in the command's own result, where history keeps it.
1946
+ receipt === null ? null : h('span', { role: 'status' }, receipt.error === undefined
1947
+ ? capabilityCommandText(t, receipt.text)
1948
+ : t('capabilities.quick.writeError', { error: receipt.error }))),
1949
+ ]
1950
+ return inline === true
1951
+ ? h('div', { className: 'ptcPlusCapabilitySurface ptcPlusCapabilitySurface-inline' }, ...body)
1952
+ : h(Modal, { open, onClose: close, title: t('capabilities.quick.modalTitle'),
1953
+ closeLabel: t('settings.close'), headless: true, className: 'ptcPlusSessionCapabilityModal' }, ...body)
2031
1954
  }
2032
1955
  }