dsh-ptc-plus 0.5.2 → 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 (72) hide show
  1. package/README.en.md +4 -2
  2. package/README.md +4 -2
  3. package/client.cjs +3392 -2811
  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/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  8. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  9. package/docs/adr/0023-global-user-bindings.md +12 -0
  10. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  11. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  13. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
  14. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  15. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  16. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  17. package/docs/adr/README.md +7 -1
  18. package/docs/architecture.md +1 -1
  19. package/docs/capability-planning-components.md +6 -6
  20. package/docs/capability-projection.md +1 -1
  21. package/docs/capability-surface.md +133 -0
  22. package/docs/client-ui.md +17 -15
  23. package/docs/client-visual-contract.md +7 -7
  24. package/docs/durability-design.md +9 -2
  25. package/docs/installation.md +17 -10
  26. package/docs/panel-concept.md +171 -0
  27. package/docs/releases/v0.5.2.md +26 -14
  28. package/docs/releases/v0.5.3.md +77 -0
  29. package/docs/releases/v0.5.4.md +75 -0
  30. package/docs/runtime-reference.md +21 -1
  31. package/docs/user-bindings.en.md +15 -0
  32. package/docs/user-bindings.md +15 -0
  33. package/docs/v0.5.3-goals.md +117 -0
  34. package/docs/verification.md +4 -4
  35. package/index.js +176 -41
  36. package/internal/binding-adoption.js +228 -0
  37. package/internal/capability-command-receipt.js +106 -8
  38. package/internal/capability-environment.js +1109 -312
  39. package/internal/capability-plan.js +18 -2
  40. package/internal/capability-policy.js +13 -0
  41. package/internal/capability-selection.js +330 -0
  42. package/internal/config-spec.js +14 -5
  43. package/internal/cordis-tools-owner.js +2 -1
  44. package/internal/direct-surface-owner.js +44 -17
  45. package/internal/edit-transport-owner.js +6 -5
  46. package/internal/execution-provider-owner.js +6 -0
  47. package/internal/execution-seam-compat.js +25 -0
  48. package/internal/host-tool-sdk.js +308 -0
  49. package/internal/message-sources.js +21 -0
  50. package/internal/official-repl-priority.js +31 -10
  51. package/internal/program-bindings.js +25 -5
  52. package/internal/repl-memory-projection.js +21 -2
  53. package/internal/runtime-bridge-owner.js +118 -9
  54. package/internal/runtime-config.js +10 -0
  55. package/internal/runtime-messages.js +5 -0
  56. package/internal/session-journal-recovery.js +210 -32
  57. package/internal/session-journal-schema.js +3 -0
  58. package/internal/session-journal.js +46 -9
  59. package/internal/session-log-view.js +69 -7
  60. package/internal/session-runtime.js +106 -39
  61. package/internal/settings-compat.js +6 -0
  62. package/internal/user-bindings-owner.js +126 -6
  63. package/package.json +59 -37
  64. package/src/client-authoring-view.js +7 -8
  65. package/src/client-capabilities-view.js +874 -909
  66. package/src/client-capability-sort.js +22 -9
  67. package/src/client-copy.js +118 -272
  68. package/src/client-indicator-view.js +2 -1
  69. package/src/client-repl-view.js +35 -16
  70. package/src/client-settings-view.js +90 -33
  71. package/src/client-styles.js +67 -19
  72. package/src/client.js +49 -22
@@ -1,18 +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
+ /**
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.
21
+ */
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 ?? '' })
54
+ }
55
+
12
56
  export function capabilityCommandText(t, text) {
13
57
  if (text?.key) return t(text.key, text.params)
14
58
  const receipt = readCapabilityCommandReceipt(text)
15
- return receipt ? t(`capabilities.receipt.${receipt.state}`, { session: receipt.sessionId, error: receipt.error ?? '' }) : text
59
+ return receipt === undefined ? text : capabilityReceiptNotice(t, receipt, 'receipt')
16
60
  }
17
61
 
18
62
  // Host-owned context the model sees in PTC mode. The plugin never receives the host's
@@ -32,13 +76,13 @@ const PROMPT_TABLE_COLUMNS = PROMPT_TABLE_COLUMN_KEYS.length
32
76
  function capabilityChoiceLabel(t, value) {
33
77
  return t(value === 'inherit' ? 'capabilities.quick.noOverride'
34
78
  : ['enabled', 'pending', 'unknown'].includes(value) ? `capabilities.quick.${value}`
35
- : [...selections, ...CAPABILITY_DISCLOSURES].includes(value) ? `capabilities.${value}` : 'capabilities.quick.unknown')
79
+ : [...selections, ...CAPABILITY_DISCLOSURES].includes(value) ? `capabilities.word.${value}` : 'capabilities.quick.unknown')
36
80
  }
37
81
 
38
- function capabilityRuleLabel(t, rule, empty = 'capabilities.policy.inheritRule') {
82
+ function capabilityRuleLabel(t, rule, empty = 'capabilities.policy.layerAbove') {
39
83
  const dimensions = ['availability', 'disclosure'].filter(dimension => rule?.[dimension] !== undefined)
40
84
  return dimensions.length ? dimensions.map(dimension => `${t(`capabilities.policy.${dimension}`)}: ${rule[dimension] === 'automatic'
41
- ? t('capabilities.policy.inheritRule') : capabilityChoiceLabel(t, rule[dimension])}`).join(' · ') : t(empty)
85
+ ? t('capabilities.policy.layerAbove') : capabilityChoiceLabel(t, rule[dimension])}`).join(' · ') : t(empty)
42
86
  }
43
87
 
44
88
  function validCatalogRule(rule, temporary = false) {
@@ -48,6 +92,100 @@ function validCatalogRule(rule, temporary = false) {
48
92
  ? ['enabled', 'closed'].includes(choice) : CAPABILITY_DISCLOSURES.includes(choice))))
49
93
  }
50
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
+
51
189
  /** A Chat snapshot without a node store cannot order a receipt against the conversation. */
52
190
  const noSnapshot = () => undefined
53
191
 
@@ -71,17 +209,67 @@ export function firstHumanMessageSeq(nodes) {
71
209
 
72
210
  /**
73
211
  * A capability receipt belongs to chat only after the conversation has opened.
74
- * A blank Session has nothing to announce, and a receipt recorded before the
75
- * first human message describes a previous attempt's publication. A truncated
76
- * Chat window cannot prove where the conversation opened, so its earliest
77
- * 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.
78
222
  */
79
- export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true } = {}) {
223
+ export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true,
224
+ written = false } = {}) {
80
225
  if (blank === true) return false
226
+ if (written === true) return true
81
227
  if (windowComplete !== true) return true
82
228
  return !(Number.isSafeInteger(node?.seq) && Number.isSafeInteger(firstUserSeq) && node.seq < firstUserSeq)
83
229
  }
84
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
+
85
273
  export function createCapabilityCommandCard(React) {
86
274
  const h = React.createElement
87
275
  return function CapabilityCommandCard({ node, t, useChat, useSession }) {
@@ -100,17 +288,37 @@ export function createCapabilityCommandCard(React) {
100
288
  // not chat, so a node without that submitted input is not a user command and
101
289
  // renders no notice: a fresh session has no change to announce.
102
290
  if (typeof node.args !== 'string') return null
103
- if (!capabilityReceiptVisible(node, { blank, firstUserSeq, windowComplete })) return null
104
- const command = `/${node.name ?? 'capabilities'}${node.args ? ` ${String(node.args).trim()}` : ''}`
105
291
  const text = node.outcome?.text
106
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()}` : ''}`
107
298
  // Pending is the plan's publication state, not an outcome: it becomes true
108
299
  // only at the next accepted model step, so a durable notice would state a
109
300
  // future that a historical row can no longer verify.
110
301
  if (receipt?.state === 'pending') return null
111
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
+ }
112
318
  if (receipt || String(node.args ?? '').trim() === 'refresh' || legacyRefresh) {
113
- 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')
114
322
  : legacyRefresh ? t('capabilities.card.recorded')
115
323
  : node.outcome ? (typeof text === 'string' && !text.trim() ? undefined : capabilityCommandText(t, text))
116
324
  ?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
@@ -123,10 +331,13 @@ export function createCapabilityCommandCard(React) {
123
331
  if (typeof text === 'string') {
124
332
  try { json = JSON.parse(text) } catch { /* Plain command outcomes remain readable text. */ }
125
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.
126
337
  const entries = json?.schema === 'ptc-capability-catalog/v1' && Array.isArray(json.entries)
127
338
  && json.entries.every(entry => entry && typeof entry.id === 'string'
128
- && ['name', 'description', 'effective', 'saved', 'temporary']
129
- .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]))
130
341
  && ['savedRule', 'temporaryRule'].every(key => entry[key] === undefined || validCatalogRule(entry[key], key === 'temporaryRule'))) ? json.entries : null
131
342
  const outcome = entries ? t('capabilities.card.catalogCount', { count: entries.length })
132
343
  : json ? t('capabilities.card.details')
@@ -134,13 +345,10 @@ export function createCapabilityCommandCard(React) {
134
345
  ?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
135
346
  : t('capabilities.refreshing')
136
347
  const label = (entry, key) => {
137
- const ruleKey = key === 'saved' ? 'savedRule' : key === 'temporary' ? 'temporaryRule' : 'effectiveDimensions'
138
- const rule = entry[ruleKey]
139
- if (rule !== undefined && (key !== 'effective' || validCatalogRule({ availability: rule?.availability, disclosure: rule?.disclosure }))) {
140
- const dimensions = capabilityRuleLabel(t, rule, key === 'temporary' ? 'capabilities.quick.noOverride' : 'capabilities.policy.inheritRule')
141
- return key === 'effective' ? `${capabilityChoiceLabel(t, entry.effective)} (${dimensions})` : dimensions
142
- }
143
- 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')
144
352
  }
145
353
  return h('section', { className: 'ptcPlusCapabilityReceipt', 'aria-label': t('capabilities.receipt.title') },
146
354
  h('div', { className: 'ptcPlusCapabilityReceiptHead' },
@@ -152,12 +360,12 @@ export function createCapabilityCommandCard(React) {
152
360
  entries?.length ? h('details', { className: 'ptcPlusCapabilityReceiptCatalog' },
153
361
  h('summary', null, t('capabilities.card.browse')),
154
362
  h('div', { className: 'ptcPlusCapabilityReceiptTable' }, h('table', null,
155
- 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']
156
364
  .map(key => h('th', { key, scope: 'col' }, t(`capabilities.${key}`))))),
157
365
  h('tbody', null, ...entries.map((entry, index) => h('tr', { key: `${entry.id}-${index}` },
158
366
  h('td', null, h('strong', null, entry.name ?? entry.id),
159
367
  entry.description ? h('small', null, entry.description) : null),
160
- ...['effective', 'saved', 'temporary'].map(key => h('td', { key }, label(entry, key))))))))) : null,
368
+ ...['saved', 'temporary'].map(key => h('td', { key }, label(entry, key))))))))) : null,
161
369
  text ? h('details', { className: 'ptcPlusCapabilityReceiptSource',
162
370
  onToggle: event => setShowSource(event.currentTarget.open) },
163
371
  h('summary', null, t('capabilities.card.source')),
@@ -166,28 +374,8 @@ export function createCapabilityCommandCard(React) {
166
374
  }
167
375
 
168
376
  const selections = ['automatic', 'full', 'on-demand', 'closed']
169
- const kinds = ['all', 'tool', 'skill', 'resource', 'other']
170
377
  const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
171
378
  const configurationId = () => Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
172
-
173
- /** Catalog facts and saved preferences stay separate from frozen session results. */
174
- export function capabilitySettingsEntries(value, catalog, plans, uncertain = false) {
175
- const baseline = JSON.parse(value.capabilityBaseline ?? CONFIG_DEFAULTS.capabilityBaseline)
176
- const entries = new Map((catalog ?? []).map(entry => [entry.id, { ...entry, available: true }]))
177
- for (const plan of plans ?? []) for (const entry of plan.effective.entries) {
178
- if (!entries.has(entry.id)) entries.set(entry.id, { id: entry.id, category: entry.category })
179
- }
180
- const policy = capabilityPolicyForConfig(value)
181
- for (const id of [...baseline, ...Object.keys(policy.items)].filter(id => !id.startsWith('mcpServers.'))) if (!entries.has(id)) entries.set(id, { id })
182
- return [...entries.values()].map(entry => ({ ...entry,
183
- name: entry.name ?? entry.id,
184
- kind: ['tool', 'skill'].includes(entry.category) ? entry.category
185
- : ['resource', 'resource-template'].includes(entry.category) ? 'resource' : 'other',
186
- availability: catalog === null || uncertain ? 'unknown' : entry.available ? 'available' : 'unavailable',
187
- common: baseline.includes(entry.id), rule: policy.items[entry.id] ?? {}, resolvedRule: capabilityRule(policy, entry),
188
- }))
189
- }
190
-
191
379
  export function capabilityPlanReason(t, entry) {
192
380
  const known = ['manual-override', 'baseline-not-selected', 'baseline', 'coverage-below-threshold', 'statistical-coverage',
193
381
  'mcp-server-disabled', 'contract-budget', 'management-disabled', 'statistics-disabled', 'statistics-settings-invalid', 'statistics-data-invalid',
@@ -196,7 +384,7 @@ export function capabilityPlanReason(t, entry) {
196
384
  .map(reason => known.includes(reason) ? t(`capabilities.reason.${reason}`) : reason).join(' · ')
197
385
  }
198
386
 
199
- export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal = createFallbackModal(React) }) {
387
+ export function createCapabilitySettingsViews(React, { Select, Modal = createFallbackModal(React) }) {
200
388
  const h = React.createElement
201
389
  const field = key => CONFIG_FIELDS.find(item => item.key === key)
202
390
  // Each group owns one settings tab; the prompt text switch lives in the same
@@ -285,8 +473,13 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
285
473
  )
286
474
  }
287
475
 
288
- function PolicyRules({ value, catalog, disabled, onChange, t }) {
289
- 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)
290
483
  const write = next => onChange(field('capabilityPolicy'), JSON.stringify(next))
291
484
  const ruleControls = (rule, update, label, defaults = false) => h('div', { className: 'ptcPlusPolicyRule', key: label },
292
485
  h('strong', null, label), ...['availability', 'disclosure'].map(dimension => h('div', { className: 'ptcPlusCapabilityFilter', key: dimension },
@@ -295,7 +488,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
295
488
  value: rule[dimension] ?? 'automatic', onChange: choice => {
296
489
  const next = { ...rule }; if (choice === 'automatic') delete next[dimension]; else next[dimension] = choice; update(next)
297
490
  }, options: [...(defaults ? [] : ['automatic']), ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
298
- .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}`) })) }))))
299
492
  return h('section', { className: 'ptcPlusPolicyRules', 'aria-label': t('capabilities.policy.rules') },
300
493
  h('h4', null, t('capabilities.inheritedRules')), h('p', { className: 'ptcPlusDetail' }, t('capabilities.inheritedRulesHelp')),
301
494
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.policy.precedence')),
@@ -304,7 +497,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
304
497
  ...Object.values(CAPABILITY_INVENTORY_CATEGORIES).map(category => ruleControls(policy.categories[category] ?? {}, rule =>
305
498
  write({ ...policy, categories: { ...policy.categories, [category]: rule } }), t(`capabilities.policy.category.${category}`)))),
306
499
  h('details', null, h('summary', { tabIndex: 0 }, t('capabilities.sourceRules')),
307
- ...[...new Set([...Object.keys(policy.mcpServers), ...(catalog ?? []).map(item => item.mcpServer).filter(Boolean)])].sort(compare)
500
+ ...[...new Set(Object.keys(policy.mcpServers))].sort(compare)
308
501
  .map(server => ruleControls(policy.mcpServers[server] ?? {}, rule =>
309
502
  write(changeCapabilityRule(changeCapabilityRule(policy, `mcpServers.${server}`, 'availability', rule.availability ?? 'automatic'),
310
503
  `mcpServers.${server}`, 'disclosure', rule.disclosure ?? 'automatic')), server))))
@@ -322,340 +515,23 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
322
515
  footer ? h('div', { className: 'ptcPlusCapabilityDialogFooter' }, footer) : null)
323
516
  }
324
517
 
325
- function CapabilityBatchDialog({ operation, value, entries, disabled, onChange, onClose, t }) {
326
- const [choices, setChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
327
- const [pending, setPending] = React.useState(false)
328
- const [failed, setFailed] = React.useState(false)
329
- const saving = React.useRef(false)
330
- const mounted = React.useRef(true)
331
- React.useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
332
- const targetIds = new Set(entries.filter(entry => entry.availability === 'available').map(entry => entry.id))
333
- const missing = operation.targets.filter(entry => !targetIds.has(entry.id)).length
334
- const blocked = disabled('capabilityPolicy') || pending
335
- const changed = Object.values(choices).some(choice => choice !== 'keep')
336
- const close = () => { if (!saving.current) onClose() }
337
- const confirm = async () => {
338
- if (saving.current || blocked || missing || !changed || !operation.targets.length) return
339
- let next = capabilityPolicyForConfig(value)
340
- for (const target of operation.targets) for (const dimension of ['availability', 'disclosure']) {
341
- if (choices[dimension] !== 'keep') next = changeCapabilityRule(next, target.id, dimension, choices[dimension])
342
- }
343
- saving.current = true; setPending(true); setFailed(false)
344
- try {
345
- const saved = await onChange(field('capabilityPolicy'), JSON.stringify(next))
346
- if (!mounted.current) return
347
- if (saved === true) onClose()
348
- else setFailed(true)
349
- } catch {
350
- if (mounted.current) setFailed(true)
351
- } finally {
352
- saving.current = false
353
- if (mounted.current) setPending(false)
354
- }
355
- }
356
- return h(Modal, { open: true, onClose: close, title: t('capabilities.batchSettings'),
357
- closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
358
- h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
359
- h('button', { type: 'button', className: 'ptcPlusDialogClose', disabled: pending,
360
- 'aria-label': t('capabilities.batch.cancel'), onClick: close }, '×')),
361
- h('div', { className: 'ptcPlusCapabilityBatchBody' },
362
- h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.targets', { count: operation.targets.length })),
363
- operation.excluded ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.excluded', { count: operation.excluded })) : null,
364
- operation.targets.length ? h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
365
- ...operation.targets.map(target => h('li', { key: target.id }, h('span', null, target.name), h('code', null, target.id))))
366
- : h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.empty')),
367
- h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
368
- h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
369
- h(Select, { value: choices[dimension], disabled: blocked || !operation.targets.length,
370
- label: t(`capabilities.batch.${dimension}`), onChange: choice => setChoices(current => ({ ...current, [dimension]: choice })),
371
- options: ['keep', 'automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
372
- .map(choice => ({ value: choice, label: t(choice === 'keep' ? 'capabilities.batch.keep'
373
- : choice === 'automatic' ? 'capabilities.policy.inheritRule'
374
- : choice === 'enabled' ? 'capabilities.action.enable'
375
- : choice === 'closed' ? 'capabilities.action.close' : `capabilities.${choice}`) })) })))),
376
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.effect')),
377
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sourceClosure')),
378
- missing ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.changedTargets', { count: missing })) : null,
379
- failed ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.failed')) : null),
380
- h('div', { className: 'ptcPlusCapabilityBatchFooter' },
381
- h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending, onClick: close }, t('capabilities.batch.cancel')),
382
- h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
383
- disabled: blocked || Boolean(missing) || !changed || !operation.targets.length, onClick: confirm },
384
- t(pending ? 'state.syncing' : 'capabilities.batch.confirm', { count: operation.targets.length }))))
385
- }
386
-
387
- function CapabilityChoices({ value, catalog, catalogError, plans, plansError, disabled, onChange, onReload, t, selectedSessionId, onSessionSelect }) {
388
- const root = React.useRef(null)
389
- const [query, setQuery] = React.useState('')
390
- const [kind, setKind] = React.useState('all')
391
- const [source, setSource] = React.useState('all')
392
- const [strategy, setStrategy] = React.useState('all')
393
- const [availabilityRule, setAvailabilityRule] = React.useState('all')
394
- const [onlyCommon, setOnlyCommon] = React.useState(false)
395
- const [availability, setAvailability] = React.useState('all')
396
- const [sort, setSort] = React.useState(null)
397
- const [selected, setSelected] = React.useState(null)
398
- const [picked, setPicked] = React.useState([])
399
- const [filtersOpen, setFiltersOpen] = React.useState(false)
400
- const [batchOperation, setBatchOperation] = React.useState(null)
401
- const [inheritedRulesOpen, setInheritedRulesOpen] = React.useState(false)
402
- const [localSessionId, setLocalSessionId] = React.useState('')
403
- const sessionId = onSessionSelect ? selectedSessionId : localSessionId
404
- const setSessionId = next => { setLocalSessionId(next); onSessionSelect?.(next) }
405
- const baseline = JSON.parse(value.capabilityBaseline ?? CONFIG_DEFAULTS.capabilityBaseline)
406
- const policy = capabilityPolicyForConfig(value)
407
- const entries = capabilitySettingsEntries(value, catalog, plans, Boolean(catalogError))
408
- const sourceKey = entry => JSON.stringify([entry.source?.kind ?? 'unknown', entry.source?.server ?? null,
409
- entry.source?.bucket ?? null, entry.source?.provider ?? null])
410
- const sourceLabel = entry => {
411
- if (entry.source?.kind === 'skill') {
412
- const bucket = entry.source.bucket
413
- const known = ['project-dsh', 'project-agents', 'user-dsh', 'user-agents', 'runtime', 'bundled', 'custom']
414
- const label = known.includes(bucket) ? t(`capabilities.source.${bucket}`)
415
- : bucket ? `${t('capabilities.source.other-skill')} · ${bucket}` : t('capabilities.source.unknown')
416
- return `${label}${entry.source.provider ? ` · ${entry.source.provider}` : ''}`
417
- }
418
- if (entry.source?.kind === 'mcp') return `MCP${entry.source.server ? ` · ${entry.source.server}` : ''}`
419
- return t(`capabilities.source.${entry.source?.kind === 'host' ? 'host' : 'unknown'}`)
420
- }
421
- const matchesKind = (entry, type) => type === 'all' || (type === 'resource' ? entry.source?.kind === 'mcp'
422
- : type === 'tool' ? entry.kind === 'tool' && entry.source?.kind !== 'mcp' : type === entry.kind)
423
- const sources = new Map(entries.filter(entry => matchesKind(entry, kind)).map(entry => [sourceKey(entry), sourceLabel(entry)]))
424
- const setType = next => { setKind(next); if (!entries.some(entry => matchesKind(entry, next) && sourceKey(entry) === source)) setSource('all') }
425
- const setCompatibleSource = next => {
426
- setSource(next)
427
- if (next !== 'all' && !entries.some(entry => matchesKind(entry, kind) && sourceKey(entry) === next)) setKind('all')
428
- }
429
- const normalizedQuery = query.trim().toLowerCase()
430
- const filtered = entries.filter(entry => matchesKind(entry, kind)
431
- && (source === 'all' || sourceKey(entry) === source)
432
- && (strategy === 'all' || (entry.rule.disclosure ?? 'automatic') === strategy)
433
- && (availabilityRule === 'all' || (entry.rule?.availability ?? 'automatic') === availabilityRule)
434
- && (!onlyCommon || entry.common)
435
- && (availability === 'all' || entry.availability === availability)
436
- && `${entry.name} ${entry.id} ${entry.description ?? ''} ${sourceLabel(entry)}`.toLowerCase().includes(normalizedQuery))
437
- const defaultOrder = (a, b) => compare(a.name.toLowerCase(), b.name.toLowerCase()) || compare(a.id, b.id)
438
- const state = item => item.availability === 'unknown' ? 'unknown' : item.availability === 'unavailable' ? 'unavailable'
439
- : value.capabilityContextEnabled === false || value.capabilityManagementEnabled !== true ? 'unapplied'
440
- : item.resolvedRule.availabilitySource.startsWith('mcp:') && item.resolvedRule.availability === 'closed' ? 'source-closed' : item.resolvedRule.availability
441
- const type = item => item.source?.kind === 'mcp' ? 'resource' : item.kind
442
- const displayedSource = item => item.source?.kind === 'mcp' ? item.source.server ?? sourceLabel(item) : sourceLabel(item)
443
- const groups = new Map()
444
- for (const entry of filtered) {
445
- const group = entry.source?.kind === 'mcp' && entry.source.server ? `mcp:${entry.source.server}`
446
- : entry.availability === 'unavailable' ? 'unavailable' : entry.kind
447
- if (!groups.has(group)) groups.set(group, [])
448
- groups.get(group).push(entry)
449
- }
450
- const groupOrder = ['tool', ...[...groups.keys()].filter(key => key.startsWith('mcp:')).sort(compare), 'skill', 'resource', 'other', 'unavailable']
451
- const defaultEntries = groupOrder.flatMap(group => (groups.get(group) ?? []).sort(defaultOrder))
452
- const visible = sortCapabilityEntries(defaultEntries, sort, (item, column) => column === 'name' ? item.name
453
- : column === 'type' ? t(`capabilities.type.${type(item)}`)
454
- : column === 'source' ? displayedSource(item)
455
- : column === 'availability' ? t(`capabilities.rowState.${state(item)}`)
456
- : t(`capabilities.${item.resolvedRule.disclosure}`))
457
- const pickedIds = picked.filter(id => entries.some(item => item.id === id))
458
- const pickedEntries = pickedIds.map(id => entries.find(item => item.id === id)).filter(Boolean)
459
- const pickedTargets = pickedEntries.filter(item => item.availability === 'available')
460
- const allVisiblePicked = visible.length > 0 && visible.every(item => pickedIds.includes(item.id))
461
- const someVisiblePicked = visible.some(item => pickedIds.includes(item.id))
462
- const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
463
- const toggleAllVisible = () => setPicked(current => allVisiblePicked
464
- ? current.filter(id => !visible.some(item => item.id === id))
465
- : [...new Set([...current, ...visible.map(item => item.id)])])
466
- const entry = visible.find(item => item.id === selected)
467
- const plan = sessionId ? plans?.find(item => item.sessionId === sessionId) : undefined
468
- const effective = plan?.effective.entries.find(item => item.id === entry?.id)
469
- const change = (key, next) => onChange(field(key), JSON.stringify(next))
470
- const setManual = (id, selection) => change('capabilityPolicy', changeCapabilityRule(policy, id, 'disclosure', selection))
471
- const clear = () => { setQuery(''); setKind('all'); setSource('all'); setStrategy('all'); setAvailabilityRule('all'); setOnlyCommon(false); setAvailability('all') }
472
- const activeFilters = Number(kind !== 'all') + Number(source !== 'all') + Number(strategy !== 'all') + Number(availabilityRule !== 'all') + Number(onlyCommon) + Number(availability !== 'all')
473
- const select = (label, current, update, options, blocked = false) => h('div', { className: 'ptcPlusCapabilityFilter' },
474
- h('span', null, label), h(Select, { label, value: current, disabled: blocked, onChange: update,
475
- options: options.map(([key, text]) => ({ value: key, label: text })) }))
476
- const editorField = (label, control) => h('div', { className: 'ptcPlusCapabilityField' }, h('span', null, label), control)
477
- const manualSelect = item => h('div', { className: 'ptcPlusCapabilityEditorFields' },
478
- editorField(t('capabilities.enabledColumn'), h(Select, { value: item.rule.availability ?? 'automatic', disabled: disabled('capabilityPolicy'),
479
- label: t('capabilities.policy.availabilityFor', { name: item.id }),
480
- onChange: next => change('capabilityPolicy', changeCapabilityRule(policy, item.id, 'availability', next)),
481
- options: ['automatic', 'enabled', 'closed'].map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : choice === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${choice}`) })) })),
482
- editorField(t('capabilities.contextColumn'), h(Select, { value: item.rule.disclosure ?? 'automatic', disabled: disabled('capabilityPolicy'),
483
- label: t('capabilities.disclosureFor', { name: item.id }),
484
- onChange: next => setManual(item.id, next), options: ['automatic', ...CAPABILITY_DISCLOSURES]
485
- .map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${choice}`) })) })))
486
- const commonSwitch = item => h('div', { className: 'ptcPlusCapabilitySwitchRow' },
487
- h('div', { className: 'ptcPlusCapabilitySwitchText' },
488
- h('span', { className: 'ptcPlusCapabilitySwitchLabel' }, t('capabilities.commonBaseline')),
489
- h('span', { className: 'ptcPlusCapabilitySwitchHint' }, t(item.common ? 'capabilities.inBaseline' : 'capabilities.notInBaseline'))),
490
- h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', checked: item.common,
491
- disabled: disabled('capabilityBaseline'), 'aria-label': t('capabilities.baselineFor', { name: item.id }),
492
- onChange: event => change('capabilityBaseline', event.target.checked
493
- ? [...baseline.filter(id => id !== item.id), item.id] : baseline.filter(id => id !== item.id)) }))
494
- const filterLabels = [kind !== 'all' ? t(`capabilities.type.${kind}`) : null,
495
- source !== 'all' ? sources.get(source) ?? t('capabilities.previousSource') : null,
496
- strategy !== 'all' ? t(strategy === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${strategy}`) : null,
497
- availabilityRule !== 'all' ? t(availabilityRule === 'automatic' ? 'capabilities.policy.inheritRule'
498
- : availabilityRule === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed') : null,
499
- onlyCommon ? t('capabilities.onlyCommon') : null,
500
- availability !== 'all' ? t(`capabilities.availability.${availability}`) : null].filter(Boolean)
501
- const closeEditor = () => {
502
- const button = [...(root.current?.querySelectorAll('[data-capability-edit-id]') ?? [])].find(item => item.dataset.capabilityEditId === selected)
503
- setSelected(null); button?.focus({ preventScroll: true })
504
- }
505
- return h('div', { className: 'ptcPlusCapabilityChoices', ref: root },
506
- h('div', { className: 'ptcPlusCapabilityToolbar' },
507
- h('label', { className: 'ptcPlusSearch' }, h(SearchIcon),
508
- h('input', { type: 'search', value: query, 'aria-label': t('capabilities.search'), placeholder: t('capabilities.search'),
509
- onChange: event => setQuery(event.target.value) })),
510
- h('div', { className: 'ptcPlusCapabilityTypes', role: 'group', 'aria-label': t('capabilities.types') },
511
- ...kinds.filter(key => key !== 'other' || entries.some(item => item.kind === 'other')).map(key => h('button', {
512
- key, type: 'button', 'aria-pressed': kind === key, onClick: () => setType(key),
513
- }, t(`capabilities.type.${key}`)))),
514
- h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
515
- onClick: () => setFiltersOpen(true) }, t('capabilities.filterAction')),
516
- h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
517
- 'aria-label': t('capabilities.batchSettings'),
518
- disabled: disabled('capabilityPolicy') || pickedTargets.length === 0,
519
- onClick: () => setBatchOperation({
520
- targets: pickedTargets.map(item => ({ id: item.id, name: item.name })),
521
- excluded: pickedIds.length - pickedTargets.length,
522
- }) }, t('capabilities.batchSettings'),
523
- pickedIds.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
524
- title: t('capabilities.selectionCount', { count: pickedIds.length }) },
525
- String(pickedIds.length)) : null),
526
- h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
527
- onClick: () => setInheritedRulesOpen(true) }, t('capabilities.inheritedRules'))),
528
- filtersOpen ? h(CapabilityDialog, { title: t('capabilities.filterAction'), onClose: () => setFiltersOpen(false), t,
529
- className: 'ptcPlusCapabilityFilterDialog', footer: h('button', { type: 'button', className: 'ptcPlusButton',
530
- 'data-kind': 'primary', onClick: () => setFiltersOpen(false) }, t('capabilities.dialogDone')) },
531
- h('div', { className: 'ptcPlusCapabilityFilterFields' },
532
- select(t('capabilities.sourceFilter'), source, setCompatibleSource, [['all', t('capabilities.allSources')], ...sources,
533
- ...(source !== 'all' && !sources.has(source) ? [[source, t('capabilities.previousSource')]] : [])]),
534
- select(t('capabilities.policy.disclosureFilter'), strategy, setStrategy, [['all', t('capabilities.allStrategies')],
535
- ...['automatic', ...CAPABILITY_DISCLOSURES].map(key => [key, t(key === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${key}`)])]),
536
- select(t('capabilities.policy.availabilityFilter'), availabilityRule, setAvailabilityRule, [['all', t('capabilities.allStrategies')],
537
- ...['automatic', 'enabled', 'closed'].map(key => [key, t(key === 'automatic' ? 'capabilities.policy.inheritRule' : key === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed')])]),
538
- h('label', { className: 'ptcPlusCapabilityOnlyCommon' }, h('input', { type: 'checkbox', checked: onlyCommon,
539
- onChange: event => setOnlyCommon(event.target.checked) }), t('capabilities.onlyCommon')),
540
- select(t('capabilities.availabilityFilter'), availability, setAvailability,
541
- ['all', 'available', 'unavailable', 'unknown'].map(key => [key, t(`capabilities.availability.${key}`)])))) : null,
542
- inheritedRulesOpen ? h(CapabilityDialog, { title: t('capabilities.inheritedRules'), onClose: () => setInheritedRulesOpen(false), t,
543
- footer: h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
544
- onClick: () => setInheritedRulesOpen(false) }, t('capabilities.dialogDone')) },
545
- h(PolicyRules, { value, catalog, disabled, onChange, t })) : null,
546
- batchOperation ? h(CapabilityBatchDialog, { operation: batchOperation, value, entries, disabled, onChange,
547
- onClose: () => setBatchOperation(null), t }) : null,
548
- activeFilters ? h('div', { className: 'ptcPlusActiveFilters' }, h('span', null, filterLabels.join(' · ')),
549
- h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: clear }, t('capabilities.clear'))) : null,
550
- h('div', { className: 'ptcPlusCapabilityResultLine' }, h('span', { role: 'status' }, t('capabilities.results', { count: visible.length, total: entries.length })),
551
- h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: onReload }, t('capabilities.reloadCatalog'))),
552
- catalogError ? h('p', { role: 'status', className: 'ptcPlusMessage' }, t('capabilities.catalogUnavailable', { error: catalogError })) : null,
553
- entries.length === 0 ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.catalogEmpty'))
554
- : visible.length === 0 ? h('div', { className: 'ptcPlusCapabilityEmpty' }, h('p', null, t('capabilities.noMatches')),
555
- h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: clear }, t('capabilities.clear'))) :
556
- h('div', { className: 'ptcPlusCapabilityTable', role: 'table', 'aria-label': t('capabilities.management') },
557
- h('div', { className: 'ptcPlusCapabilityColumns', role: 'row' },
558
- ...CAPABILITY_SORT_COLUMNS.map(column => h('span', { key: column.key, role: 'columnheader',
559
- 'aria-sort': capabilitySortDirection(sort, column.key),
560
- ...(column.key === 'name' ? { 'aria-label': t('capabilities.member'), className: 'ptcPlusCapabilityPickHead' } : {}) },
561
- ...(column.key === 'name' ? [h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: allVisiblePicked,
562
- ref: node => { if (node) node.indeterminate = someVisiblePicked && !allVisiblePicked },
563
- disabled: visible.length === 0, 'aria-label': t('capabilities.selectAllVisible'),
564
- onChange: toggleAllVisible })] : []),
565
- h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
566
- onClick: () => setSort(current => cycleCapabilitySort(current, column.key)) },
567
- t(`capabilities.${column.label}`), h('span', { 'aria-hidden': true },
568
- capabilitySortDirection(sort, column.key) === 'ascending' ? '↑' : capabilitySortDirection(sort, column.key) === 'descending' ? '↓' : '↕'))))),
569
- h('div', { className: 'ptcPlusCapabilityList', role: 'rowgroup' }, ...visible.map(item => h('div', { key: item.id, className: 'ptcPlusCapabilityItem', role: 'row',
570
- 'data-selected': entry?.id === item.id, 'data-availability': item.availability,
571
- onClick: () => setSelected(item.id) },
572
- h('div', { className: 'ptcPlusCapabilityIdentity', role: 'cell' },
573
- h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: pickedIds.includes(item.id),
574
- 'aria-label': t('capabilities.selectForBatch', { name: item.name ?? item.id }),
575
- onClick: event => event.stopPropagation(), onChange: () => togglePick(item.id) }),
576
- h('div', { className: 'ptcPlusCapabilityIdentityText' },
577
- h('span', { className: 'ptcPlusCapabilityName' }, item.name), h('code', null, item.id))),
578
- h('span', { className: 'ptcPlusCapabilityBadge', role: 'cell', 'data-kind': type(item) }, t(`capabilities.type.${type(item)}`)),
579
- h('span', { className: 'ptcPlusCapabilitySource', role: 'cell' }, displayedSource(item)),
580
- // The cell stays a plain cell so its empty area selects the row; only the switch and
581
- // its own label swallow the click, keeping a toggle from also opening the row editor.
582
- h('div', { className: 'ptcPlusCapabilityAvailability', role: 'cell', 'data-state': state(item) },
583
- h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', id: `ptcPlusCapabilityAvailability-${item.id}`,
584
- checked: item.resolvedRule.availability === 'enabled',
585
- disabled: disabled('capabilityPolicy') || state(item) === 'source-closed',
586
- 'aria-label': t('capabilities.policy.availabilityFor', { name: item.id }),
587
- onClick: event => event.stopPropagation(),
588
- onChange: event => change('capabilityPolicy', changeCapabilityRule(policy, item.id, 'availability', event.target.checked ? 'enabled' : 'closed')) }),
589
- h('label', { htmlFor: `ptcPlusCapabilityAvailability-${item.id}`,
590
- onClick: event => event.stopPropagation() }, t(`capabilities.rowState.${state(item)}`))),
591
- h('div', { role: 'cell', className: 'ptcPlusCapabilityContextCell' }, h('button', { type: 'button', className: 'ptcPlusCapabilityContext', 'data-capability-edit-id': item.id,
592
- 'aria-label': t('capabilities.editContextFor', { name: item.id }), 'aria-haspopup': 'dialog',
593
- onClick: event => { event.stopPropagation(); setSelected(item.id) } },
594
- h('span', null, t(`capabilities.${item.resolvedRule.disclosure}`)),
595
- h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, 'aria-hidden': true },
596
- h('path', { d: 'm15 5 4 4M4 20l4-1L20 7l-4-4L4 15Z' })))))))),
597
- entry ? h(CapabilityDialog, { title: entry.name, onClose: closeEditor, t, className: 'ptcPlusCapabilityEditorDialog',
598
- footer: h('button', { type: 'button', className: 'ptcPlusButton', onClick: closeEditor }, t('capabilities.closeDetails')) },
599
- h('code', null, entry.id), h('p', { className: 'ptcPlusDetail' }, t('capabilities.contextHelp')),
600
- manualSelect(entry), commonSwitch(entry),
601
- h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
602
- : value.capabilityManagementEnabled !== true ? 'capabilities.effect.rulesOff'
603
- : entry.resolvedRule.availabilitySource.startsWith('mcp:') && entry.resolvedRule.availability === 'closed' ? 'capabilities.effect.sourceClosed'
604
- : entry.resolvedRule.availability === 'closed' ? 'capabilities.effect.closed'
605
- : `capabilities.effect.disclosure.${entry.resolvedRule.disclosure}`)),
606
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.policy.resolved', {
607
- availability: t(entry.resolvedRule.availability === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed'),
608
- disclosure: t(`capabilities.${entry.resolvedRule.disclosure}`),
609
- source: `${entry.resolvedRule.availabilitySource} · ${entry.resolvedRule.disclosureSource}` })),
610
- entry.rule.disclosure !== undefined ? h('button', { type: 'button', className: 'ptcPlusCapabilityLink', disabled: disabled('capabilityPolicy'),
611
- onClick: () => setManual(entry.id, 'automatic') }, t('capabilities.restoreAutomatic')) : null,
612
- entry.source?.kind === 'mcp' && entry.mcpServer ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
613
- h('h5', null, t('capabilities.sourceRules')),
614
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.serverHint')),
615
- h('div', { className: 'ptcPlusCapabilityEditorFields' },
616
- ...['availability', 'disclosure'].map(dimension => editorField(t(`capabilities.policy.${dimension}`),
617
- h(Select, { value: policy.mcpServers[entry.mcpServer]?.[dimension] ?? 'automatic', disabled: disabled('capabilityPolicy'),
618
- label: `${entry.mcpServer} · ${t(`capabilities.policy.${dimension}`)}`,
619
- onChange: next => change('capabilityPolicy', changeCapabilityRule(policy, `mcpServers.${entry.mcpServer}`, dimension, next)),
620
- options: ['automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)].map(key => ({ value: key,
621
- label: t(key === 'automatic' ? 'capabilities.policy.inheritRule' : key === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${key}`) })) }))))) : null,
622
- entry.kind === 'skill' ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.skillDisclosure')) : null,
623
- entry.kind === 'skill' && entry.invocationConflict ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.invocation.varies')) : null,
624
- entry.kind === 'skill' && entry.invocation ? h('p', { className: 'ptcPlusDetail' },
625
- t(entry.invocation.modelInvocable ? entry.invocation.userInvocable ? 'capabilities.invocation.both'
626
- : 'capabilities.invocation.model' : 'capabilities.invocation.user', { name: entry.name })) : null,
627
- entry.description ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
628
- h('h5', null, t('capabilities.catalogDescription')), h('p', { className: 'ptcPlusCapabilityProse' }, entry.description)) : null,
629
- entry.availability === 'unavailable' ? h('button', { type: 'button', className: 'ptcPlusCapabilityLink',
630
- disabled: disabled('capabilityBaseline') || disabled('capabilityPolicy'), onClick: () => {
631
- const next = { ...policy, items: { ...policy.items } }; delete next.items[entry.id]; change('capabilityPolicy', next)
632
- if (baseline.includes(entry.id)) change('capabilityBaseline', baseline.filter(id => id !== entry.id))
633
- } }, t('capabilities.removeSaved')) : null,
634
- value.capabilityContextEnabled !== false && value.capabilityManagementEnabled === true ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
635
- h('h5', null, t('capabilities.currentSession')),
636
- editorField(t('capabilities.sessionSelector'), h(Select, { value: sessionId ?? '', label: t('capabilities.sessionSelector'), onChange: setSessionId,
637
- options: [{ value: '', label: t('capabilities.chooseSession') }, ...(plans ?? []).map(item => ({ value: item.sessionId, label: item.sessionId }))] })),
638
- plansError ? h('p', { role: 'status', className: 'ptcPlusDetail' }, t('capabilities.unavailable', { error: plansError }))
639
- : !plan ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.selectObservation')) : h(React.Fragment, null,
640
- h('p', null, effective ? t(`capabilities.${effective.selection}`) : t('capabilities.notInSession')),
641
- effective ? h('p', { className: 'ptcPlusDetail' }, capabilityPlanReason(t, effective)) : null,
642
- effective?.evidence ? h('p', { className: 'ptcPlusDetail' }, `${effective.evidence.used}/${effective.evidence.eligible} (${effective.evidence.scope})`) : null,
643
- plan.pendingRefresh ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.pending')) : null)) : null) : null)
644
-
645
- }
646
-
647
518
  function AllocationEvidence({ allocation, allocationError, allocationPending, selectedSessionId, statistics = false, onReload, t }) {
648
519
  const [statisticsScope, setStatisticsScope] = React.useState('workspace')
649
520
  if (allocationPending) return h('p', { role: 'status' }, t('state.syncing'))
650
521
  if (allocationError) return h('p', { role: 'status', className: 'ptcPlusMessage' }, t('capabilities.unavailable', { error: allocationError }))
651
- 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
652
523
  const { plan, coverage, configuration } = allocation
653
524
  const count = selection => plan.entries.filter(entry => entry.selection === selection).length
654
525
  const evidenceFor = item => coverage?.[item.id]?.[statisticsScope]
655
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')
656
532
  const coverageText = evidence => usable(evidence) && Number.isFinite(evidence.coverage) ? `${Math.round(evidence.coverage * 100)}%`
657
533
  : usable(evidence) ? t('capabilities.statStatus.no-samples')
658
- : t(`capabilities.statStatus.${evidence?.status ?? 'unavailable'}`)
534
+ : sampleText(evidence)
659
535
  return h('section', { className: 'ptcPlusAllocationEvidence', 'aria-label': t(statistics ? 'capabilities.observedUsage' : 'capabilities.budgetPreview') },
660
536
  h('div', { className: 'ptcPlusCapabilityResultLine' }, h('h4', null, t(statistics ? 'capabilities.observedUsage' : 'capabilities.budgetPreview')),
661
537
  h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: onReload }, t('capabilities.reloadObservation'))),
@@ -666,14 +542,14 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
666
542
  !statistics ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.coldStartHint')) : null,
667
543
  !statistics ? h(React.Fragment, null,
668
544
  h('div', { className: 'ptcPlusAllocationMetrics' },
669
- ...['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}`)))),
670
546
  h('div', null, h('strong', null, `${plan.budgets.contractBytes} / ${configuration.capabilityBudgetBytes}`), h('span', null, 'UTF-8 bytes'))),
671
547
  h('progress', { max: configuration.capabilityBudgetBytes, value: Math.min(configuration.capabilityBudgetBytes, plan.budgets.contractBytes),
672
548
  'aria-label': t('capabilities.budgetPreview') }),
673
549
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.budget', { bytes: plan.budgets.contractBytes, overage: plan.budgets.overageBytes })),
674
550
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.directoryUsage', { bytes: plan.budgets.directoryBytes, budget: configuration.capabilityDirectoryBytes })),
675
551
  !plan.enabled ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.managementOff')) : null)
676
- : coverage === null ? h('p', { role: 'status' }, t('capabilities.statStatus.unavailable')) : null,
552
+ : coverage === null ? h('p', { role: 'status' }, t('capabilities.statStatus.not-reported')) : null,
677
553
  h('div', { className: 'ptcPlusCapabilityEvidenceTable' }, h('table', null,
678
554
  h('thead', null, h('tr', null, ...[t('capabilities.member'), ...(statistics
679
555
  ? [t('capabilities.usedFamilies'), t('capabilities.eligibleFamilies'), t('capabilities.coverageColumn')]
@@ -685,7 +561,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
685
561
  h('td', { key: 'eligible' }, usable(evidenceFor(item)) ? evidenceFor(item).eligible : '—'),
686
562
  h('td', { key: 'coverage' }, coverageText(evidenceFor(item)), usable(evidenceFor(item)) && Number.isFinite(evidenceFor(item).coverage)
687
563
  ? h('progress', { max: 1, value: evidenceFor(item).coverage, 'aria-label': t('capabilities.coverageFor', { name: item.id }) }) : null)]
688
- : [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`)]))
689
565
  })))))
690
566
  }
691
567
 
@@ -845,7 +721,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
845
721
  setting('group.capabilities', enabled('capabilityContextEnabled')),
846
722
  setting('capabilities.management', enabled('capabilityManagementEnabled')),
847
723
  setting('capabilities.statisticsEnabled', t(JSON.parse(configuration.capabilityStatistics).enabled ? 'capabilities.serverOn' : 'capabilities.serverOff')),
848
- setting('capabilities.defaultDisclosure', t(`capabilities.${policy.defaults.disclosure}`)),
724
+ setting('capabilities.defaultDisclosure', t(`capabilities.word.${policy.defaults.disclosure}`)),
849
725
  setting('capabilities.allocationTitle', ['baseline', 'statistics', 'budget'].filter(key => policy.allocation[key]).map(key => t(`capabilities.allocation.${key}`)).join(' · ') || '—'),
850
726
  setting('capabilities.recoveryEnabled', enabled('capabilityRecoveryEnabled')),
851
727
  setting('capabilities.previewEnabled', `${enabled('modelPreviewEnabled')}${configuration.modelPreviewEnabled ? ` · ${t('capabilities.estimatedTokens', { count: configuration.modelPreviewTokens })}` : ''}`),
@@ -878,14 +754,18 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
878
754
  const saved = choicesFor(configurationReady ? value : undefined)
879
755
  const retained = flagsFor(saved)
880
756
  const applied = flagsFor(effectiveCapabilitySettings(saved))
881
- 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)
882
762
  const write = next => onChange(field('capabilityPolicy'), JSON.stringify(next))
883
763
  const adaptive = [policy.defaults, ...Object.values(policy.categories), ...Object.values(policy.mcpServers), ...Object.values(policy.items)]
884
764
  .some(rule => rule.disclosure === 'adaptive')
885
765
  const summary = key => {
886
766
  if (choiceState(retained, applied, key) === 'unknown') return t('capabilities.combination.unknownSettings')
887
767
  if (key === 'description') {
888
- 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}`) })
889
769
  if (policy.defaults.disclosure !== 'adaptive') return description
890
770
  const criteria = ['baseline', 'statistics', 'budget'].filter(key => policy.allocation[key])
891
771
  .map(key => t(`capabilities.allocation.${key}`)).join(t('capabilities.combination.separator'))
@@ -932,7 +812,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
932
812
  h('p', { className: 'ptcPlusDetail' }, t('capabilities.descriptionDependency')),
933
813
  h('div', { className: 'ptcPlusCapabilityFilter' }, h('span', null, t('capabilities.defaultDisclosure')),
934
814
  h(Select, { value: policy.defaults.disclosure, disabled: disabled('capabilityPolicy'), label: t('capabilities.defaultDisclosure'),
935
- options: CAPABILITY_DISCLOSURES.map(choice => ({ value: choice, label: t(`capabilities.${choice}`) })),
815
+ options: CAPABILITY_DISCLOSURES.map(choice => ({ value: choice, label: t(`capabilities.word.${choice}`) })),
936
816
  onChange: disclosure => write({ ...policy, defaults: { ...policy.defaults, disclosure } }) })),
937
817
  h('p', { className: 'ptcPlusDetail' }, effect(value.capabilityManagementEnabled !== true ? 'capabilities.effect.rulesOff' : `capabilities.effect.disclosure.${policy.defaults.disclosure}`)),
938
818
  criteria,
@@ -1321,10 +1201,11 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
1321
1201
  h('p', { className: 'ptcPlusPromptIntro' }, t('capabilities.prompt.intro')))
1322
1202
  }
1323
1203
 
1324
- function CapabilityWorkbench({ value, configurationReady, catalog, catalogError, plans, plansError, plansPending, plansStale, disabled, onChange, onReload, t, renderField, renderPlans, renderRefresh, id, selectedSessionId, onSessionSelect,
1325
- 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 }) {
1326
1206
  const [tab, setTab] = React.useState('manual')
1327
1207
  const [contextSection, setContextSection] = React.useState('description')
1208
+ const [rulesOpen, setRulesOpen] = React.useState(false)
1328
1209
  const tabs = ['manual', 'budgets', 'statistics', 'prompt', 'plans']
1329
1210
  const feedback = saving ? t('state.syncing') : saveStatus && saveStatus.key !== 'status.applied' ? t(saveStatus.key, saveStatus.params)
1330
1211
  : t(configurationWritable ? 'capabilities.savedForNew' : 'footer.readOnly')
@@ -1348,7 +1229,18 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
1348
1229
  onChange: event => onChange(field('capabilityManagementEnabled'), event.target.checked) })) : null),
1349
1230
  key === 'manual' ? h(React.Fragment, null, h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
1350
1231
  : value.capabilityManagementEnabled === true ? 'capabilities.managementHelp' : 'capabilities.effect.rulesOff')),
1351
- 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)
1352
1244
  : key === 'statistics' ? h(React.Fragment, null, renderField('capabilityStatistics'),
1353
1245
  h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
1354
1246
  : choicesFor(value).statistics?.enabled === true ? 'capabilities.effect.collectionOn'
@@ -1367,21 +1259,28 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
1367
1259
  return { CapabilityWorkbench }
1368
1260
  }
1369
1261
 
1370
- /** 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
+ */
1371
1271
  export function createCapabilityRefreshView(React, commands) {
1372
1272
  const h = React.createElement
1373
- const absent = Object.freeze({ getSnapshot: () => false, subscribe: () => () => {} })
1374
1273
  return function CapabilityRefreshButton({ sessionId, enabled, t, onApplied, onInvalidated }) {
1375
- const source = React.useMemo(() => sessionId ? commands.source(sessionId) : absent, [sessionId])
1376
- const available = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
1377
1274
  const [pending, setPending] = React.useState(false)
1378
1275
  const [status, setStatus] = React.useState(null)
1379
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])
1380
1279
  React.useEffect(() => {
1381
1280
  setStatus(null)
1382
1281
  setPending(false)
1383
1282
  return () => { request.current?.abort(); request.current = null }
1384
- }, [sessionId, enabled, available])
1283
+ }, [sessionId, enabled])
1385
1284
  const cancel = event => {
1386
1285
  event.stopPropagation()
1387
1286
  request.current?.abort(); request.current = null; setPending(false)
@@ -1390,7 +1289,7 @@ export function createCapabilityRefreshView(React, commands) {
1390
1289
  }
1391
1290
  const execute = async event => {
1392
1291
  event.stopPropagation()
1393
- if (!enabled || !available || request.current) return
1292
+ if (!enabled || request.current) return
1394
1293
  const controller = new AbortController()
1395
1294
  request.current = controller
1396
1295
  setPending(true)
@@ -1414,577 +1313,643 @@ export function createCapabilityRefreshView(React, commands) {
1414
1313
  }
1415
1314
  }
1416
1315
  return h('div', { className: 'ptcPlusCapabilityRefresh' },
1417
- h('button', { type: 'button', className: 'ptcPlusButton', disabled: !enabled || !available || pending,
1418
- 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'),
1419
1318
  onClick: execute }, t(pending ? 'capabilities.refreshing' : 'capabilities.applySession')),
1420
1319
  pending ? h('button', { type: 'button', className: 'ptcPlusButton', onClick: cancel }, t('capabilities.cancelRefresh')) : null,
1421
1320
  status ? h('p', { className: 'ptcPlusDetail', role: 'status' }, capabilityCommandText(t, status))
1422
- : !available ? h('p', { className: 'ptcPlusDetail' }, t(sessionId ? 'capabilities.refreshUnavailable' : 'capabilities.chooseSession')) : null)
1321
+ : sessionId ? null : h('p', { className: 'ptcPlusDetail' }, t('capabilities.chooseSession')))
1423
1322
  }
1424
1323
  }
1425
1324
 
1426
- /** Session capability choices use the real command transaction and a read-only catalog observation. */
1427
- 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 }) {
1428
1344
  const h = React.createElement
1429
- const emptyDrafts = () => ({})
1430
- return function SessionCapabilityControls({ sessionId, open, enabled, t, onClose }) {
1431
- const source = React.useMemo(() => commands.source(sessionId), [sessionId])
1432
- const available = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
1433
- const [catalog, setCatalog] = React.useState([])
1434
- const [management, setManagement] = React.useState('unknown')
1435
- const [savedSettings, setSavedSettings] = React.useState(null)
1436
- const [loading, setLoading] = React.useState(false)
1437
- const [error, setError] = React.useState(null)
1438
- 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([])
1439
1402
  const [picked, setPicked] = React.useState([])
1440
1403
  const [batchOpen, setBatchOpen] = React.useState(false)
1441
1404
  const [batchChoices, setBatchChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
1442
- const [selectedId, setSelectedId] = React.useState(null)
1443
- const [pane, setPane] = React.useState('list')
1444
1405
  const [query, setQuery] = React.useState('')
1445
- const [kind, setKind] = React.useState('all')
1446
- const [sourceFilter, setSourceFilter] = React.useState('all')
1447
- 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.
1448
1410
  const [sort, setSort] = React.useState(null)
1449
- const [focusDimension, setFocusDimension] = React.useState(null)
1450
- const [status, setStatus] = React.useState(null)
1451
- const [pending, setPending] = React.useState(false)
1452
1411
  const request = React.useRef(null)
1453
- 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)
1454
1416
  const mounted = React.useRef(true)
1455
- const detail = React.useRef(null)
1456
- const list = React.useRef(null)
1457
- React.useEffect(() => {
1458
- if (!focusDimension) return
1459
- const field = focusDimension === 'list' ? list.current?.querySelector('[data-selected="true"] .ptcPlusSessionCapabilityName')
1460
- : detail.current?.querySelector(`[data-capability-dimension="${focusDimension}"] [role="combobox"]`)
1461
- ?? detail.current?.querySelector('[role="combobox"]')
1462
- field?.focus({ preventScroll: true })
1463
- setFocusDimension(null)
1464
- }, [focusDimension, selectedId, pane])
1465
1417
  React.useEffect(() => {
1466
1418
  mounted.current = true
1467
- 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
+ }
1468
1427
  }, [])
1469
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.
1470
1431
  request.current?.abort()
1471
1432
  request.current = null
1472
- applying.current = false
1473
- setCatalog([])
1474
- setManagement('unknown')
1475
- setSavedSettings(null)
1476
- 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([])
1477
1441
  setPicked([])
1478
1442
  setBatchOpen(false)
1479
- setSelectedId(null)
1480
- setPane('list')
1481
- setFocusDimension(null)
1482
- setStatus(null)
1483
- setPending(false)
1484
- setError(null)
1443
+ setQuery('')
1444
+ setFamily('all')
1445
+ setSource('all')
1446
+ setSort(null)
1485
1447
  }, [sessionId])
1486
1448
  const load = React.useCallback(async signal => {
1487
- setLoading(true)
1449
+ const mine = ++epoch.current
1488
1450
  try {
1489
1451
  const payload = await inspectSessionCatalog(sessionId, signal)
1490
- if (signal.aborted || !mounted.current || request.current?.signal !== signal) return
1491
- if (payload?.schema !== 'ptc-capability-session-catalog/v1' || !Array.isArray(payload.entries)) {
1492
- 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')
1493
1458
  }
1494
- setCatalog(payload.entries)
1495
- setPicked(current => current.filter(id => payload.entries.some(entry => entry.id === id)))
1496
- setManagement(['managed', 'paused', 'off', 'unknown'].includes(payload.management)
1497
- ? payload.management : 'unknown')
1498
- setSavedSettings(payload.savedSettings !== null && typeof payload.savedSettings === 'object'
1499
- && !Array.isArray(payload.savedSettings) ? payload.savedSettings : null)
1500
- setError(null)
1501
- return payload.entries
1502
- } catch (failure) {
1503
- if (!signal.aborted && mounted.current && request.current?.signal === signal) setError(String(failure?.message ?? failure))
1504
- } finally {
1505
- 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))
1506
1465
  }
1507
1466
  }, [sessionId])
1508
1467
  React.useEffect(() => {
1509
- if (!open || !enabled || !available) {
1510
- if (!applying.current) { request.current?.abort(); request.current = null }
1511
- setLoading(false)
1468
+ if (!active) {
1469
+ request.current?.abort()
1470
+ request.current = null
1512
1471
  return
1513
1472
  }
1514
- // An in-flight apply owns the shared catalog request and reloads it on completion.
1515
- if (applying.current) return
1516
1473
  const controller = new AbortController()
1517
1474
  request.current = controller
1518
1475
  void load(controller.signal)
1519
- return () => { controller.abort(); if (request.current === controller) request.current = null }
1520
- }, [open, enabled, available, load])
1521
- const choices = drafts
1522
- const choiceFor = entry => choices[entry.id] ?? entry.temporary ?? 'inherit'
1523
- // Session writes also require the saved configuration to keep capability
1524
- // management and context on; a frozen managed session cannot be edited
1525
- // while the current settings have them off.
1526
- const savedBlocks = savedSettings !== null && (savedSettings.enabled === false || savedSettings.contextEnabled === false)
1527
- const writesBlocked = management === 'paused' || management === 'off' || savedBlocks
1528
- const sourceKey = entry => entry.source?.kind === 'mcp' ? 'mcp:' + (entry.source.server ?? '')
1529
- : entry.source?.kind === 'skill' ? 'skill:' + (entry.source.bucket ?? '')
1530
- : entry.source?.kind ?? 'unknown'
1531
- const sourceLabel = entry => entry.source?.kind === 'mcp' ? `MCP · ${entry.source.server ?? ''}`
1532
- : entry.source?.kind === 'skill' ? `Skill · ${entry.source.bucket
1533
- ? t(`capabilities.source.${entry.source.bucket}`) : t('capabilities.source.unknown')}`
1534
- : t(`capabilities.source.${entry.source?.kind === 'host' ? 'host' : 'unknown'}`)
1535
- const category = entry => entry.category === 'skill' ? 'skills'
1536
- : ['server', 'resource'].includes(entry.category) ? 'mcp'
1537
- : entry.source?.kind === 'mcp' ? 'mcp_tools' : 'tools'
1538
- const type = entry => entry.category === 'server' ? 'server'
1539
- : entry.source?.kind === 'mcp' ? 'resource'
1540
- : entry.category === 'skill' ? 'skill'
1541
- : entry.category === 'resource' ? 'resource'
1542
- : entry.category === 'tool' ? 'tool' : 'other'
1543
- const displayedSource = entry => entry.source?.kind === 'mcp' ? entry.source.server ?? sourceLabel(entry) : sourceLabel(entry)
1544
- const effectiveAvailability = entry => entry.effective === 'pending' ? 'pending'
1545
- : entry.enclosingClosure || entry.effective === 'closed' ? 'closed'
1546
- : entry.effectiveDimensions?.availability
1547
- ?? (['full', 'on-demand', 'host', 'enabled'].includes(entry.effective) ? 'enabled' : 'unknown')
1548
- const effectiveContext = entry => entry.effective === 'pending' ? 'pending'
1549
- : effectiveAvailability(entry) === 'closed' ? 'closed'
1550
- : entry.effectiveDimensions?.disclosure
1551
- ?? (['full', 'on-demand', 'host'].includes(entry.effective) ? entry.effective : 'unknown')
1552
- const sources = [...new Set(catalog.filter(entry => kind === 'all' || category(entry) === kind).map(sourceKey))].sort()
1553
- const setCompatibleKind = next => {
1554
- setKind(next)
1555
- if (sourceFilter !== 'all' && !catalog.some(entry => (next === 'all' || category(entry) === next)
1556
- && sourceKey(entry) === sourceFilter)) setSourceFilter('all')
1557
- }
1558
- const setCompatibleSource = next => {
1559
- setSourceFilter(next)
1560
- if (next !== 'all' && !catalog.some(entry => (kind === 'all' || category(entry) === kind)
1561
- && sourceKey(entry) === next)) setKind('all')
1562
- }
1563
- const filtered = catalog.filter(entry => {
1564
- const text = [entry.name, entry.id, entry.description, entry.source?.server, entry.source?.bucket].filter(Boolean).join(' ').toLocaleLowerCase()
1565
- return (kind === 'all' || category(entry) === kind)
1566
- && (sourceFilter === 'all' || sourceKey(entry) === sourceFilter)
1567
- && (stateFilter === 'all' || (stateFilter === 'changed' ? Object.hasOwn(choices, entry.id) : entry.effective === stateFilter))
1568
- && text.includes(query.trim().toLocaleLowerCase())
1569
- }).sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id))
1570
- const groups = ['tools', 'skills', 'mcp', 'mcp_tools']
1571
- const defaultOrder = groups.flatMap(group => filtered.filter(entry => category(entry) === group))
1572
- const matches = sortCapabilityEntries(defaultOrder, sort, (entry, column) => column === 'name' ? entry.name ?? entry.id
1573
- : column === 'type' ? t(`capabilities.type.${type(entry)}`)
1574
- : column === 'source' ? displayedSource(entry)
1575
- : column === 'availability' ? capabilityChoiceLabel(t, effectiveAvailability(entry))
1576
- : capabilityChoiceLabel(t, effectiveContext(entry)))
1577
- const selected = matches.find(entry => entry.id === selectedId)
1578
- ?? ['tools', 'skills', 'mcp', 'mcp_tools'].map(group => matches.find(entry => category(entry) === group)).find(Boolean)
1579
- const staged = Object.entries(choices).flatMap(([id, choice]) => typeof choice === 'object'
1580
- ? Object.entries(choice).map(([dimension, selection]) => [id, selection, dimension]) : [[id, choice]])
1581
- const pickedRows = picked.map(id => catalog.find(entry => entry.id === id)).filter(Boolean)
1582
- const allMatchesPicked = matches.length > 0 && matches.every(entry => picked.includes(entry.id))
1583
- const someMatchesPicked = matches.some(entry => picked.includes(entry.id))
1584
- const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
1585
- const toggleAllMatches = () => setPicked(current => allMatchesPicked
1586
- ? current.filter(id => !matches.some(entry => entry.id === id))
1587
- : [...new Set([...current, ...matches.map(entry => entry.id)])])
1588
- // One staging rule for single and batch edits: an item keeps one temporary
1589
- // patch, "inherit" removes the dimension and reselecting the stored choice
1590
- // never dirties the draft.
1591
- const stageDimension = (current, entry, dimension, choice) => {
1592
- const next = { ...current }
1593
- const patch = typeof next[entry.id] === 'object' && next[entry.id] !== null ? { ...next[entry.id] } : {}
1594
- const original = storedDimension(entry, dimension) ?? 'inherit'
1595
- const selectedChoice = patch[dimension] ?? original
1596
- if (choice !== selectedChoice) {
1597
- if (choice === original) delete patch[dimension]
1598
- else patch[dimension] = choice
1476
+ return () => {
1477
+ controller.abort()
1478
+ if (request.current === controller) request.current = null
1599
1479
  }
1600
- if (Object.keys(patch).length) next[entry.id] = patch; else delete next[entry.id]
1601
- return next
1602
- }
1603
- const chooseDimension = (id, dimension, choice) => {
1604
- const entry = catalog.find(item => item.id === id)
1605
- if (entry) setDrafts(current => stageDimension(current, entry, dimension, choice))
1606
- }
1607
- const choose = (id, next) => {
1608
- const entry = catalog.find(item => item.id === id)
1609
- if (!entry) return
1610
- const original = entry.temporary ?? 'inherit'
1611
- if (next === (choices[id] ?? original)) return
1612
- setDrafts(current => {
1613
- const nextChoices = { ...current }
1614
- if (next === original) delete nextChoices[id]
1615
- else nextChoices[id] = next
1616
- return nextChoices
1617
- })
1618
- }
1619
- const stageBatch = (ids, selections) => setDrafts(current => ids.reduce((draft, id) => {
1620
- const entry = catalog.find(item => item.id === id)
1621
- if (!entry) return draft
1622
- return ['availability', 'disclosure'].reduce((next, dimension) =>
1623
- selections[dimension] === undefined || selections[dimension] === 'keep'
1624
- ? next : stageDimension(next, entry, dimension, selections[dimension]), draft)
1625
- }, current))
1626
- const hasInheritanceReset = staged.some(([, selection]) => selection === 'inherit')
1627
- const apply = async targetScope => {
1628
- if (applying.current || !enabled || !available || writesBlocked || staged.length === 0) return
1629
- if (targetScope === 'global' && hasInheritanceReset) return
1630
- const submitted = structuredClone(choices)
1631
- applying.current = true
1632
- setPending(true)
1633
- 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 = () => {
1634
1484
  const controller = new AbortController()
1635
1485
  request.current?.abort()
1636
1486
  request.current = controller
1637
- // One owner releases the shared request slot and the pending state on every exit path, so a
1638
- // closed, superseded or failed apply cannot strand the workbench in "applying".
1639
- const finish = () => {
1640
- if (request.current === controller) request.current = null
1641
- applying.current = false
1642
- if (mounted.current) setPending(false)
1643
- }
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)
1644
1502
  try {
1645
- let completed = 0
1646
- let pendingCount = 0
1647
- let failure = null
1648
- let savedOnly = null
1649
- for (const [id, selection, dimension] of staged) {
1650
- if (controller.signal.aborted) break
1651
- try {
1652
- const result = await commands.run(sessionId, `set ${targetScope} ${encodeURIComponent(id)} ${dimension ? dimension + ' ' : ''}${selection}`, controller.signal)
1653
- const receipt = readCapabilityCommandReceipt(result.text)
1654
- if (!receipt || receipt.sessionId !== sessionId || !['recorded', 'pending'].includes(receipt.state)) {
1655
- throw new Error(receipt ? capabilityCommandText(t, result.text) : t('capabilities.quick.unknownReceipt'))
1656
- }
1657
- if (receipt?.state === 'pending') pendingCount += 1
1658
- completed += 1
1659
- } catch (cause) {
1660
- failure = String(cause?.message ?? cause)
1661
- savedOnly = readCapabilityCommandReceipt(failure)?.state === 'saved-only'
1662
- ? readCapabilityCommandReceipt(failure) : null
1663
- break
1664
- }
1665
- }
1666
- if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1667
- const refreshed = await load(controller.signal)
1668
- if (request.current !== controller || controller.signal.aborted || !mounted.current) return
1669
- const confirmed = refreshed ? staged.slice(0, completed).filter(([id, selection, dimension]) => {
1670
- const entry = refreshed.find(item => item.id === id)
1671
- if (dimension) return entry && (targetScope === 'session'
1672
- ? (entry.temporaryRule?.[dimension] ?? 'inherit') === selection
1673
- : (entry.savedRule?.[dimension] ?? 'automatic') === selection && entry.temporaryRule?.[dimension] === undefined)
1674
- return entry && (targetScope === 'session' ? entry.temporary === selection
1675
- : entry.saved === selection && entry.temporary === 'inherit')
1676
- }) : []
1677
- setDrafts(current => {
1678
- const nextChoices = { ...current }
1679
- for (const [id, selection, dimension] of confirmed) {
1680
- if (savedOnly && id === staged[completed]?.[0] && dimension === staged[completed]?.[2]) continue
1681
- if (dimension) {
1682
- if (nextChoices[id]?.[dimension] !== submitted[id]?.[dimension]) continue
1683
- const patch = { ...nextChoices[id] }; delete patch[dimension]
1684
- if (Object.keys(patch).length) nextChoices[id] = patch; else delete nextChoices[id]
1685
- } else if (nextChoices[id] === submitted[id]) delete nextChoices[id]
1686
- }
1687
- return nextChoices
1688
- })
1689
- if (savedOnly) setStatus({ key: 'capabilities.quick.savedOnly', params: { count: completed, error: savedOnly.error ?? '' } })
1690
- else if (failure || !refreshed || confirmed.length < completed) setStatus({ key: 'capabilities.quick.partial',
1691
- params: { count: confirmed.length, error: failure ?? t('capabilities.quick.confirmationUnavailable') } })
1692
- 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 }
1693
1515
  } finally {
1694
- finish()
1516
+ writes.current.delete(controller)
1517
+ if (mounted.current) setWriting(current => current.filter(item => item !== id))
1695
1518
  }
1696
1519
  }
1697
- const discard = () => { setDrafts(emptyDrafts()); setStatus(null) }
1698
- const close = () => {
1699
- // A confirmed apply keeps its own lifecycle: refusing the close keeps an accidental
1700
- // Escape or backdrop click from aborting host commands the user already submitted.
1701
- if (applying.current) return
1702
- request.current?.abort()
1703
- request.current = null
1704
- applying.current = false
1705
- setPending(false)
1706
- setDrafts(emptyDrafts())
1707
- setStatus(null)
1708
- 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)
1709
1550
  }
1710
- const filter = (label, value, onChange, options, disabled = false) => h('div', { className: 'ptcPlusCapabilityFilter' },
1711
- h('span', null, label), h(Select, { label, value, onChange, disabled,
1712
- options: options.map(([key, text]) => ({ value: key, label: text })) }))
1713
- const ruleLabel = (rule, empty) => capabilityRuleLabel(t, rule, empty)
1714
- const statusLabel = value => value && typeof value === 'object' ? ruleLabel(value) : capabilityChoiceLabel(t, value)
1715
- // The stored temporary value for one dimension, including the legacy scalar
1716
- // form where a single selection covered both dimensions.
1717
- const storedDimension = (entry, dimension) => {
1718
- const rule = entry.temporaryRule !== null && typeof entry.temporaryRule === 'object' && !Array.isArray(entry.temporaryRule)
1719
- ? entry.temporaryRule : undefined
1720
- const override = rule?.[dimension]
1721
- if (override !== undefined) return override
1722
- if (entry.temporary === 'automatic') return 'automatic'
1723
- if (typeof entry.temporary === 'string' && !['inherit', 'automatic', 'unknown', 'pending'].includes(entry.temporary)) {
1724
- return dimension === 'availability' ? (entry.temporary === 'closed' ? 'closed' : 'enabled')
1725
- : (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')}`
1581
+ }
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}`])])
1726
1606
  }
1727
- return undefined
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(' · ')}`
1728
1615
  }
1729
- // The value a dimension falls back to when this session keeps no temporary
1730
- // change; the frozen plan already resolved the saved layers.
1731
- const frozenDimension = (entry, dimension) => {
1732
- const source = entry.sourceDimensions?.[dimension]
1733
- if (typeof source === 'string') return source
1734
- const effective = entry.effectiveDimensions?.[dimension]
1735
- if (typeof effective === 'string' && storedDimension(entry, dimension) === undefined) return effective
1736
- if (dimension === 'availability') return entry.effective === 'closed' ? 'closed'
1737
- : ['full', 'on-demand', 'host', 'enabled'].includes(entry.effective) ? 'enabled' : undefined
1738
- 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)
1739
1628
  }
1740
- const stateText = value => value === undefined ? t('capabilities.quick.unknown') : statusLabel(value)
1741
- const dimensionCurrent = (entry, dimension) => choices[entry.id]?.[dimension] ?? storedDimension(entry, dimension) ?? 'inherit'
1742
- const dimensionImpact = (entry, dimension) => {
1743
- const draft = choices[entry.id]?.[dimension]
1744
- const choice = draft ?? storedDimension(entry, dimension) ?? 'inherit'
1745
- const stagedChange = draft !== undefined
1746
- if (choice === 'inherit') return { state: frozenDimension(entry, dimension), origin: 'capabilities.quick.originPlan', staged: stagedChange }
1747
- if (choice === 'automatic') return { upper: true, staged: stagedChange }
1748
- 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
+ })
1636
+ }
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
1749
1740
  }
1750
- const impactLine = impact => impact.upper
1751
- ? t(impact.staged ? 'capabilities.quick.impactUpperPending' : 'capabilities.quick.impactUpper')
1752
- : t(impact.staged ? 'capabilities.quick.impactPending' : 'capabilities.quick.impact',
1753
- { state: stateText(impact.state), source: t(impact.origin) })
1754
- const dimensionOptions = (entry, dimension) => {
1755
- const restore = stateText(frozenDimension(entry, dimension))
1756
- const inherit = storedDimension(entry, dimension) === undefined
1757
- ? t('capabilities.quick.noChange', { state: restore })
1758
- : t('capabilities.quick.undoTemp', { state: restore })
1759
- // "Let the level above decide" only differs from "no change" while the
1760
- // session's frozen source still carries an explicit rule for the dimension.
1761
- const sourceDimension = entry.sourceRule?.[dimension]
1762
- const distinctRule = sourceDimension !== undefined && sourceDimension !== 'automatic'
1763
- return ['inherit', ...(distinctRule || dimensionCurrent(entry, dimension) === 'automatic' ? ['automatic'] : []),
1764
- ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
1765
- .map(choice => ({ value: choice, label: choice === 'inherit' ? inherit
1766
- : choice === 'automatic' ? t('capabilities.quick.upperRule')
1767
- : t(choice === 'enabled' ? 'capabilities.action.enable' : choice === 'closed' ? 'capabilities.action.close' : `capabilities.${choice}`) }))
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' }))))
1768
1769
  }
1769
- const legacyFrozenState = entry => {
1770
- const stored = typeof entry.temporary === 'string' && !['inherit', 'automatic', 'unknown', 'pending'].includes(entry.temporary)
1771
- if (stored) return ['full', 'on-demand', 'closed'].includes(entry.sessionSourceSelection) ? entry.sessionSourceSelection : undefined
1772
- 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())
1773
1787
  }
1774
- const choiceCurrent = entry => choices[entry.id] ?? entry.temporary ?? 'inherit'
1775
- const choiceImpact = entry => {
1776
- const draft = choices[entry.id]
1777
- const choice = draft ?? entry.temporary ?? 'inherit'
1778
- const stagedChange = draft !== undefined
1779
- if (choice === 'inherit') return { state: legacyFrozenState(entry), origin: 'capabilities.quick.originPlan', staged: stagedChange }
1780
- if (choice === 'automatic') return { upper: true, staged: stagedChange }
1781
- 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))
1782
1796
  }
1783
- const choiceOptions = selected ? [
1784
- 'inherit', 'automatic',
1785
- ...(selected.category === 'server' ? [] : ['full', 'on-demand']), 'closed',
1786
- ].map(choice => ({ value: choice, label: choice === 'inherit'
1787
- ? t(selected.temporary !== undefined && selected.temporary !== 'inherit' ? 'capabilities.quick.undoChange' : 'capabilities.quick.keepUnchanged')
1788
- : choice === 'automatic' ? t('capabilities.quick.upperRule')
1789
- : t(`capabilities.${choice}`) })) : []
1790
- const dimensionFacts = dimensions => ['availability', 'disclosure'].map(dimension =>
1791
- `${t(`capabilities.policy.${dimension}`)}: ${statusLabel(dimensions[dimension])} (${dimensions[`${dimension}Source`] ?? t('capabilities.quick.unknown')})`).join(' · ')
1792
- // A dimension whose effective value comes from the session-only temporary
1793
- // choice is marked beside the frozen inheritance it replaces.
1794
- const temporaryDimension = (entry, dimension) => {
1795
- const rule = entry.temporaryRule !== null && typeof entry.temporaryRule === 'object' && !Array.isArray(entry.temporaryRule)
1796
- ? entry.temporaryRule : undefined
1797
- const override = rule?.[dimension]
1798
- if (override !== undefined && override !== 'automatic') return override
1799
- if (typeof entry.temporary === 'string' && !['inherit', 'automatic'].includes(entry.temporary)) {
1800
- 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
1821
+ }
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) }
1801
1835
  }
1802
- return undefined
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: {} } })
1803
1840
  }
1804
- const dimensionCell = (entry, dimension, value) => temporaryDimension(entry, dimension) === value
1805
- ? t('capabilities.quick.temporaryState', { state: statusLabel(value) }) : statusLabel(value)
1806
- const openEditor = (entry, dimension = 'availability') => { setSelectedId(entry.id); setPane('detail'); setFocusDimension(dimension) }
1807
- const renderRow = entry => h('div', { key: entry.id, role: 'row',
1808
- className: 'ptcPlusSessionCapabilityRow ptcPlusCapabilityItem', 'data-capability-id': entry.id, 'data-selected': selected?.id === entry.id,
1809
- onClick: () => openEditor(entry) },
1810
- h('div', { role: 'cell', className: 'ptcPlusCapabilityIdentity' },
1811
- h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: picked.includes(entry.id), disabled: writesBlocked,
1812
- 'aria-label': t('capabilities.selectForBatch', { name: entry.name ?? entry.id }),
1813
- onClick: event => event.stopPropagation(), onChange: () => togglePick(entry.id) }),
1814
- h('div', { className: 'ptcPlusCapabilityIdentityText' },
1815
- h('button', { type: 'button', className: 'ptcPlusSessionCapabilityName ptcPlusCapabilityName',
1816
- 'aria-current': selected?.id === entry.id ? 'true' : undefined,
1817
- onClick: event => { event.stopPropagation(); openEditor(entry) } }, entry.name ?? entry.id),
1818
- entry.name && entry.name !== entry.id ? h('code', null, entry.id) : null)),
1819
- h('span', { role: 'cell', className: 'ptcPlusCapabilityBadge', 'data-kind': type(entry) }, t(`capabilities.type.${type(entry)}`)),
1820
- h('span', { role: 'cell', className: 'ptcPlusCapabilitySource' }, displayedSource(entry)),
1821
- h('div', { role: 'cell', className: 'ptcPlusCapabilityAvailability', 'data-state': effectiveAvailability(entry) },
1822
- h('button', { type: 'button', className: 'ptcPlusCapabilityContext',
1823
- 'aria-label': t('capabilities.policy.availabilityFor', { name: entry.id }),
1824
- title: t('capabilities.quick.effective'),
1825
- onClick: event => { event.stopPropagation(); openEditor(entry, 'availability') } }, dimensionCell(entry, 'availability', effectiveAvailability(entry)))),
1826
- h('div', { role: 'cell', className: 'ptcPlusCapabilityContextCell' },
1827
- h('button', { type: 'button', className: 'ptcPlusCapabilityContext',
1828
- 'aria-label': t('capabilities.editContextFor', { name: entry.id }),
1829
- title: t('capabilities.quick.effective'),
1830
- onClick: event => { event.stopPropagation(); openEditor(entry, 'disclosure') } }, dimensionCell(entry, 'disclosure', effectiveContext(entry)))),
1831
- h('span', { role: 'cell', className: 'ptcPlusSessionCapabilityPending', 'data-changed': Object.hasOwn(choices, entry.id),
1832
- 'data-label': t('capabilities.quick.changed') },
1833
- Object.hasOwn(choices, entry.id) ? statusLabel(choices[entry.id]) : '—'))
1834
- return h(Modal, { open, onClose: close, title: t('capabilities.quick.modalTitle'),
1835
- closeLabel: t('settings.close'), headless: true, className: 'ptcPlusSessionCapabilityModal' },
1836
- h('div', { className: 'ptcPlusSessionCapabilityHead' },
1837
- h('div', null, h('h2', null, t('capabilities.quick.modalTitle')),
1838
- h('p', null, t('capabilities.quick.sessionIdentity', { session: sessionId }))),
1839
- h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('settings.close'), disabled: pending, onClick: close }, '×')),
1840
- h('div', { className: 'ptcPlusSessionCapabilityToolbar' },
1841
- h('label', { className: 'ptcPlusSearch' },
1842
- h('svg', { viewBox: '0 0 24 24', width: 16, height: 16, 'aria-hidden': true },
1843
- h('circle', { cx: 10, cy: 10, r: 6, fill: 'none', stroke: 'currentColor', strokeWidth: 2 }),
1844
- h('path', { d: 'm15 15 6 6', fill: 'none', stroke: 'currentColor', strokeWidth: 2 })),
1845
- h('input', { type: 'search', value: query, 'aria-label': t('capabilities.search'),
1846
- placeholder: t('capabilities.search'), onChange: event => setQuery(event.target.value) })),
1847
- filter(t('capabilities.quick.kind'), kind, setCompatibleKind,
1848
- ['all', 'tools', 'skills', 'mcp', 'mcp_tools'].map(key => [key, t(`capabilities.quick.${key}`)])),
1849
- filter(t('capabilities.sourceFilter'), sourceFilter, setCompatibleSource,
1850
- [['all', t('capabilities.allSources')], ...sources.map(key => [key,
1851
- sourceLabel(catalog.find(entry => sourceKey(entry) === key))])]),
1852
- filter(t('capabilities.quick.state'), stateFilter, setStateFilter,
1853
- [['all', t('capabilities.quick.allStates')], ['changed', t('capabilities.quick.changed')],
1854
- ...['full', 'on-demand', 'closed', 'pending'].map(key => [key, statusLabel(key)])])),
1855
- // A blocked workbench states why writes are off and how to restore them; the banner styling
1856
- // keeps that condition from reading as an incidental list message.
1857
- writesBlocked ? h('p', { className: 'ptcPlusCapabilityNotice ptcPlusCapabilityBlocked', role: 'status' },
1858
- h('svg', { className: 'ptcPlusCapabilityBlockedIcon', viewBox: '0 0 16 16', width: 14, height: 14,
1859
- 'aria-hidden': true, fill: 'none' },
1860
- h('path', { d: 'M8 2.3 14.7 13.5H1.3L8 2.3Z', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round' }),
1861
- h('path', { d: 'M8 6.4v3M8 11.2v.7', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' })),
1862
- t(management === 'paused' ? 'capabilities.quick.sessionPaused'
1863
- : management === 'off' ? 'capabilities.quick.sessionManagementOff'
1864
- : savedSettings?.contextEnabled === false ? 'capabilities.quick.savedPaused'
1865
- : 'capabilities.quick.savedManagementOff')) : null,
1866
- h('div', { className: 'ptcPlusSessionCapabilityWorkspace', 'data-pane': pane },
1867
- h('div', { className: 'ptcPlusSessionCapabilityList', role: 'table', 'aria-label': t('capabilities.quick.members'), ref: list },
1868
- h('div', { className: 'ptcPlusSessionCapabilityColumns ptcPlusCapabilityColumns', role: 'row' },
1869
- ...CAPABILITY_SORT_COLUMNS.map(column => h('span', { key: column.key, role: 'columnheader',
1870
- 'aria-sort': capabilitySortDirection(sort, column.key),
1871
- ...(column.key === 'name' ? { 'aria-label': t('capabilities.member'), className: 'ptcPlusCapabilityPickHead' } : {}) },
1872
- ...(column.key === 'name' ? [h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: allMatchesPicked,
1873
- ref: node => { if (node) node.indeterminate = someMatchesPicked && !allMatchesPicked },
1874
- disabled: writesBlocked || matches.length === 0, 'aria-label': t('capabilities.selectAllVisible'),
1875
- onChange: toggleAllMatches })] : []),
1876
- h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
1877
- onClick: () => setSort(current => cycleCapabilitySort(current, column.key)) },
1878
- t(`capabilities.${column.label}`), h('span', { 'aria-hidden': true },
1879
- capabilitySortDirection(sort, column.key) === 'ascending' ? '↑' : capabilitySortDirection(sort, column.key) === 'descending' ? '↓' : '↕')))),
1880
- h('span', { role: 'columnheader' }, t('capabilities.quick.changed'))),
1881
- loading ? h('p', { role: 'status' }, t('state.syncing')) : null,
1882
- error ? h('p', { role: 'alert' }, t('capabilities.quick.catalogError', { error })) : null,
1883
- !loading && !error && matches.length === 0 ? h('p', null, t('capabilities.noMatches')) : null,
1884
- ...(sort ? [h('div', { key: 'sorted', role: 'rowgroup' }, ...matches.map(renderRow))]
1885
- : groups.filter(group => matches.some(entry => category(entry) === group))
1886
- .map(group => h('section', { key: group, className: 'ptcPlusSessionCapabilityGroup' },
1887
- h('h3', null, t(`capabilities.quick.${group}`)),
1888
- h('div', { role: 'rowgroup' }, ...matches.filter(entry => category(entry) === group).map(renderRow)))))),
1889
- h('aside', { className: 'ptcPlusSessionCapabilityDetail', 'aria-label': t('capabilities.details'), ref: detail },
1890
- h('button', { type: 'button', className: 'ptcPlusSessionCapabilityBack', onClick: () => { setPane('list'); setFocusDimension('list') } },
1891
- t('capabilities.quick.backToList')),
1892
- selected ? h(React.Fragment, null,
1893
- h('h3', null, selected.name ?? selected.id), h('code', null, selected.id),
1894
- selected.description ? h('p', null, selected.description) : null,
1895
- h('dl', null,
1896
- h('dt', null, t('capabilities.sourceFilter')), h('dd', null, sourceLabel(selected)),
1897
- h('dt', null, t('capabilities.quick.globalSaved')), h('dd', null, selected.savedRule ? ruleLabel(selected.savedRule) : statusLabel(selected.saved)),
1898
- h('dt', null, t('capabilities.quick.sessionSource')), h('dd', null, selected.sourceRule ? ruleLabel(selected.sourceRule) : statusLabel(selected.sessionSourceSelection ?? 'unknown')),
1899
- h('dt', null, t('capabilities.quick.temporary')), h('dd', null, selected.temporaryRule ? ruleLabel(selected.temporaryRule, 'capabilities.quick.noOverride') : statusLabel(selected.temporary)),
1900
- h('dt', null, t('capabilities.quick.effective')), h('dd', null, statusLabel(selected.effective)),
1901
- selected.sourceDimensions ? h(React.Fragment, null, h('dt', null, t('capabilities.policy.frozenSource')), h('dd', null, dimensionFacts(selected.sourceDimensions))) : null,
1902
- selected.effectiveDimensions ? h(React.Fragment, null, h('dt', null, t('capabilities.policy.effectiveDimensions')), h('dd', null, dimensionFacts(selected.effectiveDimensions))) : null),
1903
- selected.source?.kind === 'mcp' && selected.category !== 'server'
1904
- ? (() => {
1905
- const server = catalog.find(entry => entry.category === 'server' && entry.source?.server === selected.source.server)
1906
- if (!server) return null
1907
- const proposed = choiceFor(server)
1908
- const availability = server.savedRule ? choices[server.id]?.availability ?? server.temporaryRule?.availability ?? 'inherit' : proposed
1909
- const sourceAvailability = server.sourceRule !== undefined ? server.sourceRule.availability : server.sessionSourceSelection
1910
- const pendingClose = server.managementEnabled !== false && (availability === 'inherit' ? sourceAvailability : availability) === 'closed'
1911
- const currentlyClosed = server.enclosingClosure ?? server.effective === 'closed'
1912
- const key = currentlyClosed ? 'capabilities.quick.disabledByServer'
1913
- : pendingClose ? 'capabilities.quick.pendingServerClose' : null
1914
- return key ? h('p', { className: 'ptcPlusCapabilityNotice' }, t(key, { server: selected.source.server })) : null
1915
- })() : null,
1916
- selected.category === 'server' ? h('div', { className: 'ptcPlusSessionCapabilityChildren' },
1917
- h('h4', null, t('capabilities.quick.serverTools')),
1918
- h('ul', null, ...catalog.filter(entry => entry.id !== selected.id
1919
- && entry.source?.kind === 'mcp' && entry.source.server === selected.source?.server)
1920
- .map(entry => h('li', { key: entry.id }, entry.name ?? entry.id)))) : null,
1921
- selected.savedRule ? h(React.Fragment, null, ...['availability', 'disclosure'].map(dimension => h(React.Fragment, { key: dimension },
1922
- h('div', { className: 'ptcPlusCapabilityFilter', 'data-capability-dimension': dimension },
1923
- h('span', null, t(`capabilities.policy.${dimension}`)),
1924
- h(Select, { label: t(`capabilities.policy.${dimension}`), disabled: !enabled || !available || pending || writesBlocked,
1925
- value: dimensionCurrent(selected, dimension),
1926
- onChange: next => chooseDimension(selected.id, dimension, next),
1927
- options: dimensionOptions(selected, dimension) })),
1928
- h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': dimension },
1929
- impactLine(dimensionImpact(selected, dimension))))))
1930
- : h(React.Fragment, null,
1931
- h('div', { className: 'ptcPlusCapabilityFilter' },
1932
- h('span', null, t('capabilities.quick.choice')),
1933
- h(Select, { label: t('capabilities.quick.choice'), value: choiceCurrent(selected),
1934
- disabled: !enabled || !available || pending || writesBlocked, options: choiceOptions,
1935
- onChange: next => choose(selected.id, next) })),
1936
- h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': 'selection' },
1937
- impactLine(choiceImpact(selected)))),
1938
- (typeof choices[selected.id] === 'string' ? choices[selected.id] === 'inherit'
1939
- : Object.values(choices[selected.id] ?? {}).includes('inherit')) ? h('p', { className: 'ptcPlusDetail' },
1940
- t('capabilities.quick.inheritSessionOnly')) : null) : h('p', null, t('capabilities.noMatches')))),
1941
- batchOpen ? h(Modal, { open: true, onClose: () => setBatchOpen(false), title: t('capabilities.batchSettings'),
1942
- closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
1943
- h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
1944
- h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('capabilities.batch.cancel'),
1945
- onClick: () => setBatchOpen(false) }, '\u00d7')),
1946
- h('div', { className: 'ptcPlusCapabilityBatchBody' },
1947
- h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.selected', { count: pickedRows.length })),
1948
- h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
1949
- ...pickedRows.map(entry => h('li', { key: entry.id }, h('span', null, entry.name ?? entry.id), h('code', null, entry.id)))),
1950
- h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
1951
- h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
1952
- h(Select, { value: batchChoices[dimension], label: t(`capabilities.batch.${dimension}`),
1953
- onChange: choice => setBatchChoices(current => ({ ...current, [dimension]: choice })),
1954
- options: ['keep', 'inherit', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
1955
- .map(choice => ({ value: choice, label: choice === 'keep' ? t('capabilities.batch.keep')
1956
- : choice === 'inherit' ? t('capabilities.batch.sessionInherit')
1957
- : choice === 'enabled' ? t('capabilities.action.enable')
1958
- : choice === 'closed' ? t('capabilities.action.close') : t(`capabilities.${choice}`) })) })))),
1959
- h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sessionStage'))),
1960
- h('div', { className: 'ptcPlusCapabilityBatchFooter' },
1961
- h('button', { type: 'button', className: 'ptcPlusButton', onClick: () => setBatchOpen(false) }, t('capabilities.batch.cancel')),
1962
- h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
1963
- disabled: pickedRows.length === 0 || !['availability', 'disclosure'].some(dimension => batchChoices[dimension] !== 'keep'),
1964
- onClick: () => { stageBatch(pickedRows.map(entry => entry.id), batchChoices); setBatchOpen(false) } },
1965
- t('capabilities.batch.stage', { count: pickedRows.length })))) : null,
1966
- h('div', { className: 'ptcPlusSessionCapabilityFooter' },
1967
- h('span', { role: 'status' }, pending ? t('capabilities.quick.applying') : status ? t(status.key, status.params)
1968
- : t('capabilities.quick.changes', { count: staged.length })),
1969
- h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending || staged.length === 0, onClick: discard },
1970
- t('capabilities.quick.discard')),
1971
- // Session-scoped temporary application stays the footer's primary action; saving global
1972
- // defaults is an explicit secondary choice, so no default-looking control changes global state.
1973
- h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary', title: t('capabilities.quick.sessionHint'),
1974
- disabled: pending || !enabled || !available || writesBlocked || staged.length === 0, onClick: () => void apply('session') },
1975
- t('capabilities.quick.applySession')),
1976
- h('button', { type: 'button', className: 'ptcPlusButton',
1977
- title: t(hasInheritanceReset ? 'capabilities.quick.inheritSessionOnly' : 'capabilities.quick.globalHint'),
1978
- disabled: pending || !enabled || !available || writesBlocked || staged.length === 0 || hasInheritanceReset,
1979
- onClick: () => void apply('global') },
1980
- t('capabilities.quick.applyGlobal')),
1981
- h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
1982
- 'aria-label': t('capabilities.batchSettings'),
1983
- disabled: pending || writesBlocked || picked.length === 0,
1984
- onClick: () => { setBatchChoices({ availability: 'keep', disclosure: 'keep' }); setBatchOpen(true) } },
1985
- t('capabilities.batchSettings'),
1986
- picked.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
1987
- title: t('capabilities.selectionCount', { count: picked.length }) },
1988
- 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)
1989
1954
  }
1990
1955
  }