dsh-ptc-plus 0.5.3 → 0.5.6

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 (49) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +3 -3
  3. package/client.cjs +3326 -2881
  4. package/compiler-core.cjs +127 -127
  5. package/compiler-core.cjs.map +2 -2
  6. package/docs/README.md +1 -0
  7. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -2
  8. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -4
  9. package/docs/architecture.md +1 -1
  10. package/docs/capability-planning-components.md +6 -6
  11. package/docs/capability-projection.md +1 -1
  12. package/docs/capability-surface.md +133 -0
  13. package/docs/client-ui.md +17 -15
  14. package/docs/client-visual-contract.md +7 -7
  15. package/docs/installation.md +14 -7
  16. package/docs/panel-concept.md +171 -0
  17. package/docs/publishing.md +1 -1
  18. package/docs/release-notes.md +1 -1
  19. package/docs/releases/v0.5.4.md +79 -0
  20. package/docs/releases/v0.5.5.md +41 -0
  21. package/docs/releases/v0.5.6.md +41 -0
  22. package/docs/runtime-reference.md +14 -0
  23. package/docs/verification.md +1 -1
  24. package/index.js +117 -30
  25. package/internal/binding-adoption.js +103 -11
  26. package/internal/capability-command-receipt.js +99 -15
  27. package/internal/capability-environment.js +1108 -348
  28. package/internal/capability-plan.js +18 -2
  29. package/internal/capability-policy.js +13 -0
  30. package/internal/capability-selection.js +330 -0
  31. package/internal/direct-surface-owner.js +17 -4
  32. package/internal/edit-transport-owner.js +3 -2
  33. package/internal/host-tool-sdk.js +17 -29
  34. package/internal/message-sources.js +21 -0
  35. package/internal/program-bindings.js +13 -4
  36. package/internal/runtime-bridge-owner.js +9 -9
  37. package/internal/runtime-config.js +10 -0
  38. package/internal/session-log-view.js +47 -5
  39. package/internal/settings-compat.js +6 -0
  40. package/internal/user-bindings-owner.js +20 -3
  41. package/package.json +45 -31
  42. package/src/client-authoring-view.js +7 -8
  43. package/src/client-capabilities-view.js +871 -948
  44. package/src/client-capability-sort.js +22 -9
  45. package/src/client-copy.js +116 -294
  46. package/src/client-settings-view.js +90 -33
  47. package/src/client-styles.js +67 -19
  48. package/src/client.js +46 -21
  49. package/internal/capability-delivery-proof.js +0 -70
@@ -1,24 +1,30 @@
1
- import { capabilityRule, capabilityPolicyForConfig, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from './capability-policy.js'
1
+ import { capabilityRule, capabilityPolicyForConfig, capabilityPolicyFromRecord, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from './capability-policy.js'
2
2
  /** One session-owned capability view for presentation, discovery and preparation. */
3
3
  import { createHash } from 'node:crypto'
4
4
  import { jsonSchemaToTs, renderToolsSdk, ToolNotFoundError } from './host-tool-sdk.js'
5
5
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
6
+ // Namespace import, not a named one: an older host does not export the plugin-record
7
+ // writer at all, and a missing named export would fail this module's load instead of
8
+ // leaving the fallback carrier to the branch that checks for it.
9
+ import * as dshSession from '@deepseek-ai/dsh-session'
6
10
  import { isModelInvocable, isUserInvocable } from '@deepseek-ai/dsh-skill'
7
11
  import { createScope } from '@deepseek-ai/dsh-scope'
8
12
  import { Context } from '@deepseek-ai/cordis'
9
13
  import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
10
14
  import { readSessionLog } from './session-events.js'
11
15
  import { readCapabilityCommandReceipt } from './capability-command-receipt.js'
12
- import { normalizeCapabilityProof } from './capability-delivery-proof.js'
13
16
  import { createCapabilityPlan } from './capability-plan.js'
14
- import { PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
17
+ import { bindingAdoptionOf } from './binding-adoption.js'
18
+ import { bindingModelPreferences } from './user-binding-model-context.js'
19
+ import { CAPABILITY_ENTRY_ID, capabilityChoiceOf, capabilityRuleOfSelection, capabilitySelectionOf, CAPABILITY_SELECTION_RECORD, CAPABILITY_SELECTION_SCHEMA } from './capability-selection.js'
20
+ import { CAPABILITY_PLAN_FORM, isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
15
21
  import { deepFreeze } from './record-utils.js'
16
22
  import { capabilityProfileConfiguration } from './runtime-config.js'
17
- import { readRuntimeAvailability } from './runtime-messages.js'
23
+ import { readRuntimeAvailability, readRuntimeMessage } from './runtime-messages.js'
18
24
  import { PLAN_CATEGORY_LINES, PLAN_NAVIGATION, PLAN_OVERAGE, PLAN_OVERAGE_HOLE,
19
25
  PROMPT_SLOT_IDS, planCategorySlotId, resolvePromptText, resolvePromptTextConfig } from './prompt-text.js'
20
26
 
21
- export const CAPABILITY_PLAN_FORM = 'capability-plan'
27
+ export { CAPABILITY_PLAN_FORM }
22
28
  export const CAPABILITY_EVIDENCE_KEY = 'dshPtcPlusCapabilities'
23
29
  const EVIDENCE_KEY = CAPABILITY_EVIDENCE_KEY
24
30
  const DIRECT_TRANSPORT = 'run_code'
@@ -138,14 +144,15 @@ function sourcePlan(message, id) {
138
144
  const snapshot = source.snapshot
139
145
  const generation = ['ptc-capability-environment/v1', 'ptc-capability-environment/v2', 'ptc-capability-environment/v3',
140
146
  'ptc-capability-environment/v4', 'ptc-capability-environment/v5'].indexOf(snapshot?.schema)
141
- // Historical generations each had one planner generation for a given settings
142
- // shape, so their plan schema is part of the record's identity. Generation 5
143
- // records the plan words instead, and its plan schema follows from the recorded
144
- // settings: the recomputation below proves the whole plan, which makes a separate
145
- // schema guess both unnecessary and wrong (a v5 snapshot may carry context
146
- // settings and therefore a v3 plan, or a closed plan and therefore a v2 one).
147
- const expectedPlanSchema = generation === 2 ? 'ptc-capability-plan/v2'
148
- : generation >= 3 && snapshot.settings.policy !== undefined ? 'ptc-capability-plan/v3' : 'ptc-capability-plan/v1'
147
+ // A historical generation planned with exactly one planner generation for the settings
148
+ // shape its schema records: a context generation (v4) always plans v3, a policy
149
+ // generation (v3) plans v2, and v1/v2 planned v1. Generation 5 records the plan words
150
+ // instead, and its plan schema follows from the recorded settings: the recomputation
151
+ // below proves the whole plan, which makes a separate schema guess both unnecessary and
152
+ // wrong (a v5 snapshot may carry context settings and therefore a v3 plan, or a closed
153
+ // plan and therefore a v2 one).
154
+ const expectedPlanSchema = generation >= 3 ? 'ptc-capability-plan/v3'
155
+ : generation === 2 ? 'ptc-capability-plan/v2' : 'ptc-capability-plan/v1'
149
156
  if (source.digest !== digest(snapshot) || generation < 0
150
157
  || !Array.isArray(snapshot.capabilities)
151
158
  || (generation < 4 && snapshot.plan?.schema !== expectedPlanSchema)) {
@@ -313,16 +320,97 @@ function sectionDependencies(sections) {
313
320
  .map(section => ({ id: section.name, text: section.text }))
314
321
  }
315
322
 
323
+ /**
324
+ * One section with the servers no program of this step may name removed from its directory.
325
+ *
326
+ * The directory lists the servers a program may name as the `server` argument, so a server
327
+ * this session closed leaves the list, and so does every server when the step delivers no
328
+ * member that takes one (`nameable`): the names are the argument of the three resource
329
+ * operations and of nothing else. A directory this build cannot read is left exactly as the
330
+ * Host wrote it: an unreadable declaration is no evidence about a closure.
331
+ */
332
+ function recordKeptServers(decisions, section, nameable = true) {
333
+ if (section.name !== 'mcp-resource-servers') return [section]
334
+ if (!nameable) return []
335
+ if (decisions.servers.size === 0) return [section]
336
+ let servers
337
+ try { servers = resourceServers([section]) } catch { return [section] }
338
+ const kept = servers.filter(server => !decisions.servers.has(server))
339
+ if (kept.length === servers.length) return [section]
340
+ return kept.length === 0 ? []
341
+ : [{ ...section, text: `Use the following MCP resource servers as the server argument: ${JSON.stringify(kept)}.` }]
342
+ }
343
+
344
+ /** The three Host resource operations a server row can name, in the Host's own order. */
345
+ const RESOURCE_OPERATIONS = Object.freeze(['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'])
346
+
347
+ /**
348
+ * The invocation sentence one resource server row states for the operations it still has.
349
+ *
350
+ * A saved policy states the operations it delivers. The legacy layer states the whole path in
351
+ * one sentence while every operation is available and states the remaining ones otherwise, so
352
+ * an unchanged legacy session reads the bytes it always read and a closure still withdraws the
353
+ * call it took away. A row may never instruct a call this session closed.
354
+ */
355
+ function resourceOperationNotice(operations, policyLayer) {
356
+ if (!policyLayer && operations.length === RESOURCE_OPERATIONS.length) {
357
+ return 'Use tools.list_mcp_resources({server}), tools.list_mcp_resource_templates({server}), and tools.read_mcp_resource({server,uri}); use the listed URI or expand a listed template.'
358
+ }
359
+ return operations.length ? 'Available operations: ' + operations.map(name => `tools.${name}(${name === 'read_mcp_resource' ? '{server,uri}' : '{server}'})`).join(', ') + '.'
360
+ : 'No resource operation is available in this session.'
361
+ }
362
+
316
363
  function requiresCapabilityInventory(settings) {
317
364
  settings = effectiveCapabilitySettings(settings)
318
365
  return settings.enabled || settings.policy && (settings.statistics?.enabled === true || settings.recoveryEnabled)
319
366
  }
320
367
 
321
- function temporaryCapabilityRule(selection) {
322
- if (selection && typeof selection === 'object') return { ...selection }
323
- return selection === undefined || selection === 'inherit' ? {}
324
- : selection === 'automatic' ? { availability: 'automatic', disclosure: 'automatic' }
325
- : selection === 'closed' ? { availability: 'closed' } : { availability: 'enabled', disclosure: selection }
368
+ /**
369
+ * The one-selection spelling of a recorded choice, or `undefined` when the
370
+ * record states a dimension that spelling cannot carry.
371
+ *
372
+ * A record states dimensions, a saved decision states one selection, and the two
373
+ * vocabularies meet exactly at the shapes `capabilityRuleOfSelection` produces
374
+ * for a whole selection. Anything narrower (a single dimension) stays a dimension
375
+ * rule, because writing it as a selection would also decide the other dimension.
376
+ */
377
+ function compactSelection(choice) {
378
+ const dimensions = Object.keys(choice).sort().join(',')
379
+ return dimensions === 'availability' && choice.availability === 'closed' ? 'closed'
380
+ : dimensions === 'availability,disclosure' && choice.availability === 'enabled'
381
+ && ['full', 'on-demand'].includes(choice.disclosure) ? choice.disclosure
382
+ : dimensions === 'availability,disclosure' && choice.availability === 'automatic'
383
+ && choice.disclosure === 'automatic' ? 'automatic' : undefined
384
+ }
385
+
386
+ /**
387
+ * This session's recorded choices, in the vocabulary a composition reads.
388
+ *
389
+ * The record is the owner: a composition that needs this session's choices takes
390
+ * them from here and from nowhere else. `state.sessionOverrides` and a previous
391
+ * composition are not inputs — they are what an earlier composition made of this
392
+ * same record, so reading them back would let a stale composition outvote a
393
+ * decision the log already holds.
394
+ */
395
+ function sessionOverridesOf(statement) {
396
+ if (statement?.state !== 'decided') return {}
397
+ const overrides = {}
398
+ for (const [id, choice] of Object.entries(statement.selection.choices)) {
399
+ overrides[id] = compactSelection(choice) ?? { ...choice }
400
+ }
401
+ return overrides
402
+ }
403
+
404
+ /** Two override maps state the same choices when they state the same decisions. */
405
+ function sameChoices(left, right) {
406
+ // One decision has two spellings: the whole selection a saved configuration states and
407
+ // the dimensions a record or a policy rule states. A plan delivered by an earlier
408
+ // release may hold either, so the comparison asks what each map decides — through the
409
+ // one mapping that owns that equivalence — rather than how this build spells it.
410
+ const canonical = overrides => JSON.stringify(Object.keys(overrides ?? {}).sort().map(id =>
411
+ [id, Object.entries(capabilityRuleOfSelection(overrides[id]))
412
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)]))
413
+ return canonical(left) === canonical(right)
326
414
  }
327
415
 
328
416
  function savedConfiguration(settings) {
@@ -347,6 +435,21 @@ function sdkMemberCall(name, callArguments) {
347
435
  : `tools[${JSON.stringify(name)}](${callArguments})`
348
436
  }
349
437
 
438
+ /**
439
+ * One managed `tools:sdk` render: the members this step declares plus the edit
440
+ * transport, produced by the Host's own renderer so the bytes are the Host's.
441
+ *
442
+ * The direct transport is never a member: it is how a program is called, not
443
+ * something the model calls against. The edit transport is callable inside every
444
+ * program and belongs to no disclosure decision, so it is declared whenever the
445
+ * Host declares it at all.
446
+ */
447
+ function renderManagedSdk(candidates, members) {
448
+ const declared = members.filter(schema => !TRANSPORTS.has(schema.name))
449
+ const editTransport = candidates.find(schema => schema.name === EDIT_TRANSPORT)
450
+ return renderToolsSdk(editTransport === undefined ? declared : [...declared, editTransport])
451
+ }
452
+
350
453
  function toolCapability(schema, sections) {
351
454
  return { id: `tools.${schema.name}`, category: CAPABILITY_INVENTORY_CATEGORIES.tool,
352
455
  revision: digest(schema), schema,
@@ -433,14 +536,30 @@ function readContractUpdate(message, planDigest, sessionId) {
433
536
  return snapshot
434
537
  }
435
538
 
436
- /** Derived metadata only: invocation continues through the request's original host functions. */
437
- export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent, modelSchemasForAgent = toolSchemasForAgent, store, sessionHistoryForAgent }) {
539
+ /**
540
+ * Derived metadata only: invocation continues through the request's original host functions.
541
+ *
542
+ * `readUserBindings` is the plugin's own store read (`list()`), supplied because
543
+ * that store belongs to the user-bindings owner: the capability read reports what
544
+ * the global catalog supplies without owning or duplicating it.
545
+ */
546
+ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent, store, sessionHistoryForAgent, readUserBindings, appendRecord: hostAppendRecord }) {
438
547
  let currentConfig = config
439
548
  const sessions = new Map()
440
549
  const histories = new WeakMap()
441
- const sessionEvents = session => Array.isArray(session?.events) ? session.events : histories.get(session) ?? []
550
+ // The host's own writer for a record outside the model-visible surface. Every supported
551
+ // release publishes it, so a session states a decision when it is made: durable at once,
552
+ // visible to the next composition, and absent from the surface the model reads.
553
+ const appendRecord = typeof hostAppendRecord === 'function' ? hostAppendRecord
554
+ : typeof dshSession.appendPluginRecord === 'function' ? dshSession.appendPluginRecord : undefined
555
+ // The committed log, read through the surface the selected Host publishes: an
556
+ // array-backed fixture states `events`, a Host Session states `snapshotEvents()`.
557
+ const sessionEvents = session => Array.isArray(session?.events) ? session.events
558
+ : typeof session?.snapshotEvents === 'function' ? session.snapshotEvents()
559
+ : histories.get(session) ?? []
560
+ const sessionQueryService = () => typeof ctx.get === 'function' ? ctx.get('sessionQuery') : ctx.sessionQuery
442
561
  async function observeHistory(agent, signal) {
443
- const query = typeof ctx.get === 'function' ? ctx.get('sessionQuery') : ctx.sessionQuery
562
+ const query = sessionQueryService()
444
563
  const log = sessionHistoryForAgent ? await sessionHistoryForAgent(agent, signal) : await readSessionLog(agent.session, query, signal)
445
564
  const events = log?.events ?? []
446
565
  histories.set(agent.session, events)
@@ -485,23 +604,35 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
485
604
  return directories?.get(agent.session) ?? agent.session.header.cwd
486
605
  }
487
606
 
488
- async function observeSkills(agent, signal) {
607
+ /**
608
+ * The Skill catalogue one supply view answers: a scope selects the viewing
609
+ * agent's layers, a directory selects workspace-sensitive Skills. A view with no
610
+ * scope is the Host's own declaration; a session whose scope the Host no longer
611
+ * holds reads the face its record froze instead, because the layers that scope
612
+ * resolved cannot be recovered without it.
613
+ */
614
+ async function observeSkillsAt(target, signal) {
489
615
  if (!readSkills(ctx)?.snapshot) return { skills: [], complete: true }
490
616
  // Read the original provider layers through a short-lived child scope. The
491
617
  // session projection is exact-scope-only, so catalogue reads cannot toggle
492
618
  // a global inventory flag and create a window where closed Skills load.
493
619
  const child = {}
494
- const isolated = typeof ctx.plugin === 'function' ? createScope(ctx, child, { parent: agent }) : null
495
- const view = isolated ? child : agent
620
+ const isolated = target.scope !== undefined && typeof ctx.plugin === 'function'
621
+ ? createScope(ctx, child, { parent: target.scope }) : null
622
+ const view = isolated ? child : target.scope
496
623
  let observed
497
624
  try {
498
- const combined = [signal, agent.signal].filter(Boolean)
499
- observed = await readSkills(ctx).snapshot({ cwd: currentDirectory(agent), scope: view,
625
+ const combined = [signal, target.signal].filter(Boolean)
626
+ observed = await readSkills(ctx).snapshot({ cwd: target.cwd, scope: view,
500
627
  signal: combined.length > 1 ? AbortSignal.any(combined) : combined[0] })
501
628
  } finally { await isolated?.dispose() }
502
629
  return { ...observed, skills: observed.skills.filter(skill => isModelInvocable(skill) || isUserInvocable(skill)) }
503
630
  }
504
631
 
632
+ function observeSkills(agent, signal) {
633
+ return observeSkillsAt({ scope: agent, cwd: currentDirectory(agent), signal: agent.signal }, signal)
634
+ }
635
+
505
636
  function recordedSkills(agent, state) {
506
637
  const skills = new Map(state.snapshot.capabilities.filter(item => item.category === 'skill').map(item => [item.id, item]))
507
638
  const history = sessionEvents(agent.session)
@@ -536,9 +667,24 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
536
667
  return state
537
668
  }
538
669
 
539
- async function inventory(agent, sections = [], settings, signal, presentation, frozenIds, skillObservation) {
670
+ /**
671
+ * Declare the capabilities of one supply view.
672
+ *
673
+ * `access` names the view when it is not the agent's own: `schemas` is the tool
674
+ * registry projection and `skills` an already-observed Skill catalogue. Both are
675
+ * what a view answers, so the caller that observed them decides which view this
676
+ * declaration describes; passing them keeps one declaration path for every view
677
+ * instead of a second inventory that could disagree with this one.
678
+ *
679
+ * `admitted` is a narrower statement of the same view: the names a step delivers. It
680
+ * restricts this environment's own contract view instead of replacing it, because a
681
+ * contract states the owner's output schema and only the SDK projection carries it — a
682
+ * capability recorded from a model-facing definition would promise `unknown`.
683
+ */
684
+ async function inventory(agent, sections = [], settings, signal, presentation, frozenIds, skillObservation, access) {
540
685
  if (settings) settings = effectiveCapabilitySettings(settings)
541
- const schemas = toolSchemasForAgent(agent).filter(schema => !TRANSPORTS.has(schema.name))
686
+ const schemas = (access?.schemas ?? toolSchemasForAgent(agent)).filter(schema => !TRANSPORTS.has(schema.name)
687
+ && (access?.admitted === undefined || access.admitted.has(schema.name)))
542
688
  const capabilities = schemas.map(schema => toolCapability(schema, sections))
543
689
  const declaredServers = new Set([...resourceServers(sections),
544
690
  ...sections.filter(section => section.name?.startsWith('mcp:')).map(section => section.name.slice(4))])
@@ -546,16 +692,39 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
546
692
  const servers = [...declaredServers].filter(server => capability.schema.name.startsWith(`mcp__${server}__`))
547
693
  if (servers.length === 1) capability.mcpServer = servers[0]
548
694
  else if (servers.length > 1) capability.mcpServerCandidates = servers
695
+ // The source names every declared server whose namespace begins this tool name.
696
+ // One name can begin with two (`mcp__docs__deep__search` is named by `docs` and
697
+ // by `docs__deep`), and that list is the supply's own fact: the panel states the
698
+ // servers it has, so dropping the list here leaves the row unable to name any.
549
699
  presentation?.set(capability.id, { name: capability.schema.name,
550
700
  source: servers.length === 1 ? { kind: 'mcp', server: servers[0] } : servers.length > 1
551
- ? { kind: 'unknown' } : { kind: 'host' } })
701
+ ? { kind: 'mcp', servers } : { kind: 'host' } })
552
702
  }
553
703
  if (skillObservation) skillObservation.complete = true
554
- const hasOperation = name => (!frozenIds || frozenIds.has(`tools.${name}`)) && capabilities.some(item => item.id === `tools.${name}`
555
- && (!settings?.enabled || !settings.policy || capabilityRule(settings.policy, item).availability !== 'closed'))
556
704
  const policyPaths = Boolean(settings?.policy)
705
+ /**
706
+ * Whether a decision closes this capability.
707
+ *
708
+ * A saved rule and a session record answer one question, so both layers are read here:
709
+ * the layer that states a closure does not change what it means. A plan composed before
710
+ * the policy generation keeps the legacy settings shape (overrides, no policy), and a
711
+ * closure recorded into it has to reach the capabilities whose only model path it is.
712
+ */
713
+ const closedByDecision = item => Boolean(settings?.enabled) && (settings.policy
714
+ ? capabilityRule(settings.policy, item).availability === 'closed'
715
+ : settings.manual.overrides[item.id] === 'closed')
716
+ /**
717
+ * Whether this composition delivers one shared operation — the definition exists in the
718
+ * inventory and no decision closes it. The legacy layer states no availability for a
719
+ * definition the Host does not declare at all, so that absence is not a closure there;
720
+ * a decision is a closure in both layers.
721
+ */
722
+ const hasOperation = name => (!frozenIds || frozenIds.has(`tools.${name}`))
723
+ && capabilities.some(item => item.id === `tools.${name}` && !closedByDecision(item))
724
+ const operationClosed = name => capabilities.some(item => item.id === `tools.${name}` && closedByDecision(item))
725
+ const operationPath = name => hasOperation(name) || (!policyPaths && !operationClosed(name))
557
726
  if (readSkills(ctx)?.snapshot !== undefined) {
558
- const observed = await observeSkills(agent, signal)
727
+ const observed = access?.skills ?? await observeSkills(agent, signal)
559
728
  if (skillObservation) {
560
729
  skillObservation.complete = observed.complete
561
730
  skillObservation.partialNames = observed.complete ? [] : observed.skills.map(skill => skill.name)
@@ -564,29 +733,30 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
564
733
  // A partial catalog cannot prove a withdrawal or a new Skill. Preserve only
565
734
  // the last complete view; native dispatch still uses the live Host scope.
566
735
  if (!observed.complete) capabilities.push(...skillObservation.retained.map(item =>
567
- retainedSkillCapability(item, !policyPaths || hasOperation('skill'))))
736
+ retainedSkillCapability(item, operationPath('skill'))))
568
737
  for (const skill of observed.complete ? observed.skills : []) {
569
- capabilities.push(skillCapability(skill, !policyPaths || hasOperation('skill')))
738
+ capabilities.push(skillCapability(skill, operationPath('skill')))
570
739
  presentation?.set(`skills.${skill.name}`, { name: skill.name,
571
740
  source: { kind: 'skill', bucket: skill.source, provider: skill.provider },
572
741
  invocation: { modelInvocable: isModelInvocable(skill), userInvocable: isUserInvocable(skill) } })
573
742
  }
574
743
  }
575
744
  const servers = resourceServers(sections)
576
- const resourceOperations = ['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource']
577
- .filter(name => !policyPaths || hasOperation(name))
745
+ const resourceOperations = RESOURCE_OPERATIONS.filter(name => operationPath(name))
746
+ const resourceNotice = resourceOperationNotice(resourceOperations, policyPaths)
578
747
  for (const server of servers) capabilities.push({ id: `mcp.${server}`, category: CAPABILITY_INVENTORY_CATEGORIES.resource, mcpServer: server,
579
748
  revision: digest({ server, instructions: sections.find(section => section.name === `mcp:${server}`)?.text ?? '' }),
580
- dependencies: sectionDependencies(sections.filter(section => section.name === `mcp:${server}`)), ...(policyPaths && resourceOperations.length === 0 ? { effectiveAvailability: 'closed' } : {}),
581
- contract: !policyPaths ? `MCP resource server ${JSON.stringify(server)}. Use tools.list_mcp_resources({server}), tools.list_mcp_resource_templates({server}), and tools.read_mcp_resource({server,uri}); use the listed URI or expand a listed template.`
582
- : `MCP resource server ${JSON.stringify(server)}. ${resourceOperations.length ? 'Available operations: ' + resourceOperations.map(name => `tools.${name}(${name === 'read_mcp_resource' ? '{server,uri}' : '{server}'})`).join(', ') + '.' : 'No resource operation is available in this session.'}`,
749
+ dependencies: sectionDependencies(sections.filter(section => section.name === `mcp:${server}`)),
750
+ ...(resourceOperations.length === 0 && (policyPaths || RESOURCE_OPERATIONS.some(operationClosed))
751
+ ? { effectiveAvailability: 'closed' } : {}),
752
+ contract: `MCP resource server ${JSON.stringify(server)}. ${resourceNotice}`,
583
753
  description: `MCP resource access registered by the Host for server ${server}. This records the registered resource layer and the session's resource operations, not a proved list of exposed resources or templates.`,
584
754
  selector: { server } })
585
755
  for (const server of servers) presentation?.set(`mcp.${server}`, { name: server, source: { kind: 'mcp', server } })
586
756
  return capabilities.filter(item => {
587
757
  if (item.category !== 'tool') return true
588
758
  const category = item.schema.name === 'skill' ? 'skill'
589
- : ['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(item.schema.name) ? 'resource' : undefined
759
+ : RESOURCE_OPERATIONS.includes(item.schema.name) ? 'resource' : undefined
590
760
  return category === undefined || capabilities.some(member => member.category === category
591
761
  && (category !== 'skill' || member.invocation.modelInvocable)
592
762
  && (!settings?.enabled || (settings.policy ? capabilityRule(settings.policy, member).availability !== 'closed' : settings.manual.overrides[member.id] !== 'closed'))
@@ -594,8 +764,105 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
594
764
  })
595
765
  }
596
766
 
767
+ /**
768
+ * What this session's log states about capability membership, as of this read.
769
+ *
770
+ * The composition is a reading of the record, not the record itself. A decision
771
+ * written after the last composition is already the session's newest statement, so a
772
+ * projection that asked only the frozen plan would keep declaring a capability the
773
+ * user closed until some later step happened to recompose — and in that window the
774
+ * SDK block, the program members and the explorable metadata answered the same
775
+ * question from two different inputs. Every projection therefore consults this
776
+ * reading, and the plan decides only what the record leaves open.
777
+ *
778
+ * The log is the record even for an agent no composition exists for yet, which is
779
+ * what lets a first assembly answer from the log instead of declaring everything.
780
+ */
781
+ const NO_DECISIONS = Object.freeze({ closed: new Set(), servers: new Set() })
782
+ const decisionsBySession = new WeakMap()
783
+ function recordDecisions(session) {
784
+ if (session === undefined || session === null) return NO_DECISIONS
785
+ const events = sessionEvents(session)
786
+ const cached = decisionsBySession.get(session)
787
+ // The log is append-only, so its length and its last event identify one reading.
788
+ if (cached !== undefined && cached.length === events.length
789
+ && cached.last === events[events.length - 1]) return cached.decisions
790
+ const closed = new Set()
791
+ const servers = new Set()
792
+ let statement
793
+ try {
794
+ statement = capabilitySelectionOf(events, String(session.id))
795
+ } catch {
796
+ // A torn record states no decision this build can read; the composition reports the
797
+ // damage on its own path, and a projection must not fail because of it.
798
+ statement = undefined
799
+ }
800
+ if (statement?.state === 'decided') {
801
+ for (const [id, choice] of Object.entries(statement.selection.choices)) {
802
+ if (capabilityRuleOfSelection(choice).availability !== 'closed') continue
803
+ if (id.startsWith('mcpServers.')) servers.add(id.slice('mcpServers.'.length))
804
+ else closed.add(id)
805
+ }
806
+ }
807
+ const decisions = Object.freeze({ closed, servers })
808
+ decisionsBySession.set(session, { length: events.length, last: events[events.length - 1], decisions })
809
+ return decisions
810
+ }
811
+ /**
812
+ * Whether the record closes this capability.
813
+ *
814
+ * A server decides its members, so a recorded `mcpServers.<server>` closure reaches
815
+ * them. The live capability's own server identity answers when it exists; otherwise the
816
+ * name is the only evidence, which is exactly how the Host names those members.
817
+ */
818
+ function recordCloses(decisions, id, capability = undefined) {
819
+ if (decisions.closed.has(id)) return true
820
+ if (decisions.servers.size === 0) return false
821
+ if (capability?.mcpServer !== undefined) return decisions.servers.has(capability.mcpServer)
822
+ if (id.startsWith('mcp.')) return decisions.servers.has(id.slice('mcp.'.length))
823
+ if (!id.startsWith('tools.')) return false
824
+ const name = id.slice('tools.'.length)
825
+ return [...decisions.servers].some(server => name.startsWith(`mcp__${server}__`))
826
+ }
827
+ /**
828
+ * Whether the record closes one member of the `tools` namespace.
829
+ *
830
+ * The two transports are the call channel rather than capabilities: `run_code` is how a
831
+ * program is called and `edit_run_code` is callable inside every program, so no record
832
+ * makes either unavailable.
833
+ */
834
+ function recordClosesMember(decisions, name) {
835
+ return !TRANSPORTS.has(name) && recordCloses(decisions, `tools.${name}`)
836
+ }
837
+ /**
838
+ * The record's closures, when capability management is what applies them.
839
+ *
840
+ * A paused context or a disabled management switch delegates the Host's capability surface:
841
+ * this environment composes nothing and filters nothing, so a choice the session recorded
842
+ * earlier stays a stored choice that applies again once management is on. Reading the record
843
+ * there would enforce a closure the switch suspended — and only half enforce it, because the
844
+ * same switch also lets the Host execute that member.
845
+ *
846
+ * A session the Host still holds keeps the composition it froze: switching management off does
847
+ * not rewrite an existing session, so its own snapshot states whether this environment still
848
+ * applies. An agent with no composition yet answers from the configuration it would compose
849
+ * under.
850
+ */
851
+ function decisionsOf(state, session = state?.session) {
852
+ const settings = effectiveCapabilitySettings(state?.snapshot.settings ?? capabilityConfiguration(currentConfig))
853
+ return settings.enabled === true ? recordDecisions(session) : NO_DECISIONS
854
+ }
855
+ /**
856
+ * The same answer for a caller that holds the agent.
857
+ *
858
+ * A projection may run before this agent has a composition — a restart, or a step assembled
859
+ * ahead of one — and the session log is still the record it reads, so the agent is what names
860
+ * the session here rather than the composition state.
861
+ */
862
+ function decisionsFor(agent) {
863
+ return decisionsOf(stateFor(agent), agent?.session)
864
+ }
597
865
  function stateFor(agent) { return sessions.get(String(agent?.session?.id ?? agent?.id)) }
598
- const canRefresh = agent => modelSchemasForAgent(agent).some(schema => schema.name === 'run_code')
599
866
  function planCommitted(agent, message) {
600
867
  return message !== undefined && (sessionEvents(agent.session).some(event =>
601
868
  event.type === 'user/message' && event.data?.id === message.id)
@@ -609,6 +876,43 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
609
876
  if (!sessionEvents(agent.session).some(event => event.type === 'system/message')) return
610
877
  agent.session.append('user/message', message, { surfaceOp: 'append' })
611
878
  }
879
+ /**
880
+ * A session capability choice is a record, not a plan edit and not a message.
881
+ *
882
+ * The panel states what this session decided, so the decision itself has to exist in
883
+ * the session's own log. The plan is what a runtime later materializes from it; a
884
+ * change that only rebuilt a plan would leave the row stating the value it replaced.
885
+ *
886
+ * A decision is made when the control is used, and nothing about that moment requires a
887
+ * turn: it is the session's statement about itself, taken through the host's plugin-record
888
+ * writer, durable at once and outside the model-visible surface. Writing it as a message
889
+ * instead would need the host's protected surface head first, and a node ahead of that
890
+ * head leaves the whole log unreadable — which is why this decision never travels that way.
891
+ */
892
+ function recordSelection(agent, overrides, signal) {
893
+ signal?.throwIfAborted()
894
+ const session = agent?.session
895
+ if (typeof session?.append !== 'function') {
896
+ throw new Error('ptc-plus: session capability choices require the public Session.append surface')
897
+ }
898
+ if (appendRecord === undefined) {
899
+ throw new Error('ptc-plus: this DSH release publishes no plugin-record writer, so a session capability choice cannot be recorded')
900
+ }
901
+ const choices = {}
902
+ for (const [id, value] of Object.entries(overrides ?? {})) {
903
+ const rule = capabilityRuleOfSelection(value)
904
+ if (Object.keys(rule).length > 0) choices[id] = rule
905
+ }
906
+ const seq = appendRecord(session, CAPABILITY_SELECTION_RECORD, { schema: CAPABILITY_SELECTION_SCHEMA, choices })
907
+ // This record is the session's newest statement, and it is already in the log the
908
+ // readers observe, so the next read answers from it without re-observing the session.
909
+ const observed = histories.get(session)
910
+ if (Array.isArray(observed)) {
911
+ const committed = sessionEvents(session).find(event => event.seq === seq)
912
+ if (committed !== undefined) histories.set(session, [...observed, committed])
913
+ }
914
+ return 'recorded'
915
+ }
612
916
  function selection(state, id) { return state?.snapshot.plan.entries.find(entry => entry.id === id)?.selection }
613
917
  function liveCapability(state, id) {
614
918
  return state?.liveCapabilities?.get(id) ?? state?.snapshot.capabilities.find(item => item.id === id)
@@ -626,6 +930,9 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
626
930
  * member's identity from the model-visible surface.
627
931
  */
628
932
  function declaredSelection(state, id) {
933
+ // A closure the record states is the session's own decision and is already durable;
934
+ // waiting for the next composition would declare a capability the user closed.
935
+ if (recordCloses(decisionsOf(state), id, liveCapability(state, id))) return 'closed'
629
936
  const planned = selection(state, id)
630
937
  if (planned !== undefined || !state?.snapshot.plan.enabled) return planned
631
938
  const live = state.liveCapabilities?.get(id)
@@ -636,17 +943,23 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
636
943
  return overrides[id] === 'closed'
637
944
  || (live.mcpServer !== undefined && overrides[`mcpServers.${live.mcpServer}`] === 'closed') ? 'closed' : 'host'
638
945
  }
639
- function supplied(state, id) {
946
+ function supplied(state, id, decisions = decisionsOf(state)) {
947
+ if (recordCloses(decisions, id, liveCapability(state, id))) return false
640
948
  if (state?.liveCapabilities && (!state.liveCapabilities.has(id) || state.liveCapabilities.get(id).effectiveAvailability === 'closed')) return false
641
949
  if (!state?.snapshot.plan.enabled) return Boolean(state?.liveCapabilities) || !state?.snapshot.settings.policy || selection(state, id) !== 'closed'
642
950
  if (!['full', 'on-demand', 'host'].includes(declaredSelection(state, id))) return false
643
951
  const capability = liveCapability(state, id)
644
- return capability?.category !== 'resource'
645
- || resourceServers(state.hostSections).includes(capability.selector.server)
952
+ if (capability?.category !== 'resource') return true
953
+ // A resource row is explorable through the operations its contract names, so it is
954
+ // supplied only while this session still delivers an operation to name — and the
955
+ // guidance and directory the row owns follow the same answer. A composition can state
956
+ // the row before a decision takes the last operation away.
957
+ return resourceServers(state.hostSections).includes(capability.selector.server)
958
+ && RESOURCE_OPERATIONS.some(name => supplied(state, `tools.${name}`, decisions))
646
959
  }
647
960
  function sharedMembers(state, name) {
648
961
  const category = name === 'skill' ? 'skill'
649
- : ['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(name) ? 'resource' : undefined
962
+ : RESOURCE_OPERATIONS.includes(name) ? 'resource' : undefined
650
963
  return category === undefined ? undefined : [...declaredCapabilities(state).values()]
651
964
  .filter(item => item.category === category && supplied(state, item.id)
652
965
  && (category !== 'skill' || (state.liveCapabilities?.get(item.id) ?? item).invocation?.modelInvocable !== false))
@@ -655,6 +968,31 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
655
968
  function memberSupplied(state, name) {
656
969
  return TRANSPORTS.has(name) || (supplied(state, `tools.${name}`) && sharedMembers(state, name)?.length !== 0)
657
970
  }
971
+ /**
972
+ * The invocation paths a declared row can state for one step.
973
+ *
974
+ * A Skill row is explorable through the shared loader and a resource server row through the
975
+ * Host's three resource operations, so those four members are the only ones whose contract
976
+ * *is* a call. `supplied` states what this session still has and the composition states the
977
+ * paths it was built under; `admitted` is the third input — the definitions this step
978
+ * delivers — and a caller that observed none keeps the composition's answer, because a
979
+ * withheld definition is a statement about the step, not about the session.
980
+ *
981
+ * A delivered path is therefore the intersection: what the step delivers, what a plan froze
982
+ * and what the session still has, so a row never gains a path the composition did not state.
983
+ * Without a plan there is nothing frozen to intersect with — the record is the whole
984
+ * composition — and `supplied` alone answers it.
985
+ */
986
+ function sharedPaths(state, admitted, decisions = decisionsOf(state)) {
987
+ const shared = name => admitted === undefined || admitted.has(name)
988
+ const planned = state?.snapshot.plan.enabled === true
989
+ return {
990
+ loader: shared('skill') && supplied(state, 'tools.skill', decisions),
991
+ operations: RESOURCE_OPERATIONS.filter(name => shared(name)
992
+ && (!planned || selection(state, `tools.${name}`) !== undefined)
993
+ && supplied(state, `tools.${name}`, decisions)),
994
+ }
995
+ }
658
996
  function statisticsSnapshot(state) {
659
997
  if (!state.liveCapabilities) return state.snapshot
660
998
  const eligible = item => state.skillInventoryComplete !== false || item.category !== 'skill'
@@ -796,52 +1134,106 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
796
1134
  return previewPolicies.get(cap)
797
1135
  }
798
1136
 
799
- async function prepare(agent, sections, force = false, signal = agent?.signal, choice) {
1137
+ /**
1138
+ * Compose this session's capability plan from the record that owns its choices.
1139
+ *
1140
+ * The session's own log states this session's choices and the saved
1141
+ * configuration states what it inherits; both are read on every composition,
1142
+ * including the incremental one a step begins with. A composition may not
1143
+ * remember them: a choice recorded before the process restarted exists in the
1144
+ * log and in no memory, and a plan that trusted its own earlier composition
1145
+ * would keep a decision the log has already replaced.
1146
+ *
1147
+ * A plan whose recorded choices already match the log is kept as it is. One that
1148
+ * disagrees is recomposed from the same saved inputs it was composed from, so a
1149
+ * session choice never silently adopts a saved configuration the session has not
1150
+ * refreshed to. `force` remains the explicit refresh: it recomposes from the
1151
+ * saved configuration as it stands now and publishes immediately.
1152
+ *
1153
+ * `delivered` is the step's admitted definition set when the caller observed one. It
1154
+ * is the same set the Host renders its own declaration from, so composing from it
1155
+ * keeps the plan, the declaration and the program members about one capability set;
1156
+ * a composition that has none to offer keeps the admission it already observed,
1157
+ * because a caller with no observation is not asking the Host to declare more.
1158
+ */
1159
+ async function prepare(agent, sections, force = false, signal = agent?.signal, delivered = undefined) {
800
1160
  signal?.throwIfAborted()
801
1161
  if (agent?.session === undefined) {
802
1162
  if (!effectiveCapabilitySettings(capabilityConfiguration(currentConfig)).enabled) return undefined
803
1163
  throw new TypeError('ptc-plus: capability management requires an agent Session')
804
1164
  }
805
- await observeHistory(agent, signal)
806
- await reconcilePendingChoice(agent, signal)
1165
+ const history = await observeHistory(agent, signal)
807
1166
  const id = String(agent.session.id ?? agent.id)
808
- if (!force && sessions.has(id)) {
809
- const state = sessions.get(id)
810
- if (sections !== undefined) state.hostSections = sections
811
- return qualifyCapabilities(agent, state, signal)
1167
+ const statement = capabilitySelectionOf(history, id)
1168
+ const sessionOverrides = sessionOverridesOf(statement)
1169
+ const current = sessions.get(id)
1170
+ if (!force && current !== undefined) {
1171
+ if (sections !== undefined) current.hostSections = sections
1172
+ if (delivered !== undefined) current.hostTools = delivered
1173
+ if (sameChoices(sessionOverrides, current.snapshot.sessionOverrides)) return qualifyCapabilities(agent, current, signal)
812
1174
  }
1175
+ // A composition that is rebuilt for a record keeps the Host sections it already
1176
+ // observed: a caller that has none to offer is not asking the Host to declare
1177
+ // less than it did, only to act on what the session recorded.
1178
+ const hostSections = sections ?? current?.hostSections ?? []
1179
+ const hostTools = delivered ?? current?.hostTools
813
1180
  const pending = operations.get(agent)
814
1181
  if (pending !== undefined && !force) return pending
815
1182
  const task = (async () => {
1183
+ // The composition this one replaces, when it replaces one: a live state, or
1184
+ // the plan the log last published. Its saved inputs are reused so the
1185
+ // replacement states the same inheritance; its plan is not reused.
1186
+ let preceding
816
1187
  if (!force) {
817
- const history = sessionEvents(agent.session)
818
- const recorded = recordedPlan(history, id)
819
- if (recorded !== undefined) {
820
- let snapshot
821
- try { snapshot = sourcePlan(recorded, id) } catch (error) {
822
- ctx.logger?.warn?.(`ptc-plus: ${error.message}; continuing with the host environment until /capabilities refresh`)
823
- const settings = { ...capabilityConfiguration(currentConfig), enabled: false, ...(currentConfig.capabilityPolicy ? { recoveryEnabled: false, previewEnabled: false, statistics: { ...capabilityConfiguration(currentConfig).statistics, enabled: false } } : {}) }
824
- const plan = createCapabilityPlan({ ...settings, configurationRevision: digest(settings), capabilities: [], navigationText: NAVIGATION })
825
- snapshot = deepFreeze({ schema: settings.contextEnabled !== undefined ? 'ptc-capability-environment/v4' : settings.policy ? 'ptc-capability-environment/v3' : 'ptc-capability-environment/v1', ...(settings.policy || settings.contextEnabled !== undefined ? { sourceSettings: settings, sessionOverrides: {} } : {}), navigationText: NAVIGATION, settings, capabilities: [], plan })
826
- const notice = planMessage(snapshot, id, true)
827
- const state = { snapshot, message: notice, hostSections: sections ?? [], expanded: new Set(), used: new Map() }
828
- return acceptState(agent, state, true, signal)
1188
+ if (current !== undefined) preceding = current.snapshot
1189
+ else {
1190
+ const recorded = recordedPlan(history, id)
1191
+ if (recorded !== undefined) {
1192
+ let snapshot
1193
+ try { snapshot = sourcePlan(recorded, id) } catch (error) {
1194
+ ctx.logger?.warn?.(`ptc-plus: ${error.message}; continuing with the host environment until /capabilities refresh`)
1195
+ const settings = { ...capabilityConfiguration(currentConfig), enabled: false, ...(currentConfig.capabilityPolicy ? { recoveryEnabled: false, previewEnabled: false, statistics: { ...capabilityConfiguration(currentConfig).statistics, enabled: false } } : {}) }
1196
+ const plan = createCapabilityPlan({ ...settings, configurationRevision: digest(settings), capabilities: [], navigationText: NAVIGATION })
1197
+ snapshot = deepFreeze({ schema: settings.contextEnabled !== undefined ? 'ptc-capability-environment/v4' : settings.policy ? 'ptc-capability-environment/v3' : 'ptc-capability-environment/v1', ...(settings.policy || settings.contextEnabled !== undefined ? { sourceSettings: settings, sessionOverrides: {} } : {}), navigationText: NAVIGATION, settings, capabilities: [], plan })
1198
+ const notice = planMessage(snapshot, id, true)
1199
+ const state = { snapshot, message: notice, hostSections, ...(hostTools === undefined ? {} : { hostTools }), expanded: new Set(), used: new Map() }
1200
+ return acceptState(agent, state, true, signal)
1201
+ }
1202
+ if (sameChoices(sessionOverrides, snapshot.sessionOverrides)) {
1203
+ const restored = { snapshot, message: snapshot.settings.contextEnabled !== undefined || snapshot.plan.enabled || snapshot.settings.policy && (snapshot.settings.statistics?.enabled || snapshot.settings.recoveryEnabled || snapshot.settings.previewEnabled) || recorded.source.refresh ? recorded : undefined, hostSections, ...(hostTools === undefined ? {} : { hostTools }), expanded: new Set(), used: new Map() }
1204
+ await qualifyCapabilities(agent, restored, signal)
1205
+ signal?.throwIfAborted()
1206
+ restoreEvidence(agent, restored)
1207
+ return acceptState(agent, restored, false, signal)
1208
+ }
1209
+ preceding = snapshot
829
1210
  }
830
- const restored = { snapshot, message: snapshot.settings.contextEnabled !== undefined || snapshot.plan.enabled || snapshot.settings.policy && (snapshot.settings.statistics?.enabled || snapshot.settings.recoveryEnabled || snapshot.settings.previewEnabled) || recorded.source.refresh ? recorded : undefined, hostSections: sections ?? [], expanded: new Set(), used: new Map() }
831
- await qualifyCapabilities(agent, restored, signal)
832
- signal?.throwIfAborted()
833
- restoreEvidence(agent, restored)
834
- return acceptState(agent, restored, false, signal)
835
1211
  }
836
1212
  }
837
1213
  const historical = !force && !sessionEvents(agent.session).some(event => event.type === 'user/message'
838
1214
  && readRuntimeAvailability(event.data) === 'disabled') && sessionEvents(agent.session).some(event =>
839
1215
  event.type === 'assistant/message' || event.type === 'tool/result')
840
- const previous = stateFor(agent)?.snapshot
841
- const sourceSettings = choice?.sourceSettings ?? { ...capabilityConfiguration(currentConfig), ...(historical ? { enabled: false, ...(currentConfig.capabilityPolicy ? { recoveryEnabled: false, previewEnabled: false, statistics: { ...capabilityConfiguration(currentConfig).statistics, enabled: false } } : {}) } : {}) }
842
- const sessionOverrides = choice?.sessionOverrides ?? previous?.sessionOverrides ?? {}
1216
+ let sourceSettings = preceding === undefined
1217
+ ? { ...capabilityConfiguration(currentConfig), ...(historical ? { enabled: false, ...(currentConfig.capabilityPolicy ? { recoveryEnabled: false, previewEnabled: false, statistics: { ...capabilityConfiguration(currentConfig).statistics, enabled: false } } : {}) } : {}) }
1218
+ : preceding.sourceSettings ?? preceding.settings
1219
+ // A record can state one dimension where a saved decision states a whole
1220
+ // selection. The policy vocabulary can hold either, and it is derived from the
1221
+ // same saved document, so the composition upgrades to it here rather than
1222
+ // dropping a dimension the record decided.
1223
+ if (sourceSettings.policy === undefined
1224
+ && Object.values(sessionOverrides).some(selection => typeof selection === 'object')) {
1225
+ sourceSettings = capabilityConfiguration(capabilityProfileConfiguration(
1226
+ savedConfiguration(sourceSettings), { allowInvalidStatistics: true }))
1227
+ }
843
1228
  const settings = sessionCapabilitySettings(sourceSettings, sessionOverrides)
844
- const capabilities = requiresCapabilityInventory(settings) ? await inventory(agent, sections ?? [], settings, signal) : []
1229
+ // The admission restricts this composition to the definitions the step delivers, and it
1230
+ // is applied to this environment's own contract view: the step publishes model-facing
1231
+ // schemas, which carry no output schema, so reading them as the contracts would record
1232
+ // a capability whose call promises `unknown`.
1233
+ const capabilities = requiresCapabilityInventory(settings)
1234
+ ? await inventory(agent, hostSections, settings, signal, undefined, undefined, undefined,
1235
+ hostTools === undefined ? undefined : { admitted: new Set(hostTools.map(schema => schema.name)) })
1236
+ : []
845
1237
  const effective = effectiveCapabilitySettings(settings)
846
1238
  const coverage = (effective.policy ? effective.enabled && effective.policy.allocation.statistics : effective.statistics?.enabled === true) ? await store?.coverage(agent, capabilities, settings.statistics) : undefined
847
1239
  const planChannel = planChannelText(currentConfig)
@@ -850,7 +1242,8 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
850
1242
  capabilities, coverage, navigationText: planChannel.navigationSentence,
851
1243
  categories: planChannel.categoryLines,
852
1244
  directoryBudgetBytes })
853
- const hasChoices = settings.contextEnabled !== undefined || settings.policy !== undefined || choice !== undefined || previous?.schema === 'ptc-capability-environment/v2'
1245
+ const hasChoices = settings.contextEnabled !== undefined || settings.policy !== undefined
1246
+ || statement.state !== 'none' || preceding?.schema === 'ptc-capability-environment/v2'
854
1247
  // An edited plan channel needs the v5 record, and that generation requires the
855
1248
  // saved inputs and session choices it was composed from.
856
1249
  const recordsText = !planChannel.isDefault
@@ -861,43 +1254,387 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
861
1254
  ...(recordsText ? planPromptText(planChannel, directoryBudgetBytes) : {}),
862
1255
  navigationText: planChannel.navigationSentence, settings, capabilities, ...(coverage === undefined ? {} : { coverage }), plan })
863
1256
  const initialized = sessionEvents(agent.session).some(event => event.type === 'system/message')
864
- const message = planMessage(snapshot, id, force && initialized)
1257
+ // A composition that replaces one already delivered says so, whether the
1258
+ // replacement came from an explicit refresh or from a record the delivered
1259
+ // plan no longer matches.
1260
+ const message = planMessage(snapshot, id, (force || preceding !== undefined) && initialized)
865
1261
  agent.signal?.throwIfAborted?.()
866
1262
  signal?.throwIfAborted()
867
1263
  // Initialized sessions commit a refresh and its notification atomically.
868
1264
  // First-step proposals are accepted through the Host's pre-step messages.
869
1265
  const state = { snapshot, message: settings.contextEnabled === undefined && !effective.enabled && !force && !(effective.policy && (effective.statistics?.enabled || effective.recoveryEnabled || effective.previewEnabled)) ? undefined : message,
870
- hostSections: sections ?? [], expanded: new Set(), used: new Map(),
1266
+ hostSections, ...(hostTools === undefined ? {} : { hostTools }), expanded: new Set(), used: new Map(),
871
1267
  ...(requiresCapabilityInventory(settings) ? { liveCapabilities: new Map(capabilities.map(item => [item.id, item])) } : {}) }
872
1268
  restoreEvidence(agent, state)
873
- if (choice?.commandId && !initialized) {
874
- state.pendingCommand = choice.commandId
875
- state.pendingPrevious = stateFor(agent)
876
- }
877
1269
  return acceptState(agent, state, force, signal)
878
1270
  })()
879
1271
  operations.set(agent, task)
880
1272
  try { return await task } finally { if (operations.get(agent) === task) operations.delete(agent) }
881
1273
  }
882
1274
 
883
- async function reconcilePendingChoice(agent, signal, requireReceipt = false) {
1275
+ function visibleSchemas(agent, schemas) {
884
1276
  const state = stateFor(agent)
885
- if (!state?.pendingCommand || planCommitted(agent, state.message)) return
886
- const history = sessionEvents(agent.session)
887
- const done = history.findLast(event => event.type === 'command/done' && event.data?.commandId === state.pendingCommand)
888
- if (!done && !requireReceipt) return
889
- const proved = recordedPlan(history, String(agent.session.id ?? agent.id))
890
- if (proved?.id === state.message.id) {
891
- delete state.pendingCommand; delete state.pendingPrevious
892
- return
1277
+ const decisions = decisionsFor(agent)
1278
+ const declared = schemas.filter(schema => !recordClosesMember(decisions, schema.name))
1279
+ if (!state?.snapshot.plan.enabled) return declared
1280
+ return declared.filter(schema => memberSupplied(state, schema.name))
1281
+ }
1282
+
1283
+ /**
1284
+ * What the Host declares to one supply view.
1285
+ *
1286
+ * A view is named by the scope the caller supplies, and the same three sources
1287
+ * answer it: prompt sections name the MCP servers and resource servers, the tool
1288
+ * registry answers that scope's view of the tools, and the Skill catalogue answers
1289
+ * the layers that scope resolves for a workspace directory. With an agent those
1290
+ * sources answer this session's declaration — a preset registers its tools at agent
1291
+ * scope, and only that scope resolves the Skill layers it selects. With no scope
1292
+ * they answer the Host's own declaration, which is what a caller that names no
1293
+ * session asks for.
1294
+ *
1295
+ * `agent` is the scope handle and nothing else. It says which declaration to read;
1296
+ * it is never asked whether the session is usable, and no runtime branch runs here.
1297
+ * The observation flag keeps the public assembly passive even when a scope is named:
1298
+ * it contributes that scope's sections without preparing or delivering anything.
1299
+ */
1300
+ async function hostSupply({ agent, cwd }, signal) {
1301
+ const scope = agent === undefined ? {} : { agent, scope: agent }
1302
+ // One failure mode: a source that cannot be read fails this read. Partial rows
1303
+ // would be a screen that silently misses what exists, which no wording repairs.
1304
+ let sections = []
1305
+ if (typeof ctx.systemPrompt?.assemble === 'function') {
1306
+ const assembly = await ctx.systemPrompt.assemble({ ...scope, signal, ptcPlusCapabilityObservation: true })
1307
+ sections = Array.isArray(assembly?.sections) ? assembly.sections : []
1308
+ // Reading the sections is the MCP layer's own step. A section this build cannot
1309
+ // interpret fails here rather than being reported as a tool-registry failure.
1310
+ resourceServers(sections)
893
1311
  }
894
- await acceptState(agent, state.pendingPrevious, false, signal)
1312
+ const schemas = toolSchemasForAgent(agent)
1313
+ const skills = readSkills(ctx)?.snapshot === undefined ? undefined
1314
+ : await observeSkillsAt({ ...scope, cwd, signal }, signal)
1315
+ const presentation = new Map()
1316
+ const capabilities = await inventory(agent, sections,
1317
+ { ...capabilityConfiguration(currentConfig), enabled: false, manual: { baseline: [], overrides: {} } },
1318
+ signal, presentation, undefined, undefined, { schemas, skills })
1319
+ return { capabilities, presentation }
895
1320
  }
896
1321
 
897
- function visibleSchemas(agent, schemas) {
898
- const state = stateFor(agent)
899
- if (!state?.snapshot.plan.enabled) return schemas
900
- return schemas.filter(schema => memberSupplied(state, schema.name))
1322
+ /**
1323
+ * The supply this session's own record froze.
1324
+ *
1325
+ * A session whose agent the Host no longer holds has no scope to observe, and the
1326
+ * Host's own plane is not that session's face: the preset tools registered at agent
1327
+ * scope and the Skill layers that scope resolved existed in that scope alone.
1328
+ * The plan this session recorded is the statement of what it was supplied, and
1329
+ * `snapshot.capabilities` is that statement. Name and source are taken from what the
1330
+ * recorded capability itself carries: a tool names itself through its schema and its
1331
+ * MCP owner, a Skill through its selector — the frozen contract records no provider
1332
+ * bucket for it — and a resource row through the server it belongs to.
1333
+ */
1334
+ function frozenSupply(snapshot) {
1335
+ const presentation = new Map()
1336
+ for (const capability of snapshot.capabilities) {
1337
+ const name = capability.schema?.name ?? capability.selector?.name
1338
+ ?? capability.selector?.server ?? capability.id
1339
+ const source = capability.mcpServer !== undefined ? { kind: 'mcp', server: capability.mcpServer }
1340
+ : capability.mcpServerCandidates !== undefined ? { kind: 'mcp', servers: capability.mcpServerCandidates }
1341
+ : { kind: capability.category === 'skill' ? 'skill'
1342
+ : capability.category === CAPABILITY_INVENTORY_CATEGORIES.resource ? 'mcp' : 'host' }
1343
+ presentation.set(capability.id, { name, source,
1344
+ ...(capability.invocation === undefined ? {} : { invocation: capability.invocation }) })
1345
+ }
1346
+ return { capabilities: snapshot.capabilities, presentation }
1347
+ }
1348
+
1349
+ /**
1350
+ * Which capabilities this session's own log proves it obtained.
1351
+ *
1352
+ * Evidence is a fact about one execution: a completed result that names the
1353
+ * capability in its evidence block. The absence of such a result is not a state
1354
+ * the panel states; it is an empty cell.
1355
+ */
1356
+ function capabilityProofOf(events) {
1357
+ const proved = new Set()
1358
+ for (const event of Array.isArray(events) ? events : []) {
1359
+ const evidence = event?.type === 'tool/result' && event.data?.meta?.[EVIDENCE_KEY]
1360
+ if (!evidence || evidence.schema !== 'ptc-capability-evidence/v1' || !Array.isArray(evidence.used)) continue
1361
+ for (const id of evidence.used) if (typeof id === 'string') proved.add(id)
1362
+ }
1363
+ return proved
1364
+ }
1365
+
1366
+ const ROW_DIMENSIONS = Object.freeze(['availability', 'disclosure'])
1367
+
1368
+ /**
1369
+ * The words this configuration wrote for one object.
1370
+ *
1371
+ * The saved column follows the same rule as the session column: it states what its own record
1372
+ * holds for that object. A rule the document wrote for the object is stated in the record's
1373
+ * words; a dimension it wrote nothing for reads `automatic`. A layer that would outrank that
1374
+ * entry at runtime — a category or `mcpServers.<server>` rule, or a document's bottom layer — is
1375
+ * behaviour rather than a word this (object, address) holds, so it changes neither the cell nor
1376
+ * the control. A server has no disclosure of its own, so a server row states availability only.
1377
+ */
1378
+ function savedChoice(configuration, capability) {
1379
+ if (capability.kind === 'server') {
1380
+ // A server's saved address is the one `mcpServers.<server>` rule it has.
1381
+ const availability = configuration.policy
1382
+ ? configuration.policy.mcpServers[capability.id.slice('mcpServers.'.length)]?.availability
1383
+ : capabilityOverrideWord(configuration, capability.id, 'availability')
1384
+ return { availability: availability ?? 'automatic' }
1385
+ }
1386
+ const rule = configuration.policy && Object.hasOwn(configuration.policy.items, capability.id)
1387
+ ? configuration.policy.items[capability.id] : undefined
1388
+ // The override record is this address's write channel only while the document holds no
1389
+ // policy. Once it does, that policy is the record — the override field stays as historical
1390
+ // input and must not answer a dimension the policy left unwritten (the server branch above
1391
+ // has the same guard), otherwise §8.2's removal would be invisible.
1392
+ const override = dimension => configuration.policy === undefined
1393
+ ? capabilityOverrideWord(configuration, capability.id, dimension) : undefined
1394
+ return { availability: rule?.availability ?? override('availability') ?? 'automatic',
1395
+ disclosure: rule?.disclosure ?? override('disclosure') ?? 'automatic' }
1396
+ }
1397
+
1398
+ /**
1399
+ * One dimension the legacy override record wrote for an object, in the record's own
1400
+ * words.
1401
+ *
1402
+ * The legacy record holds a whole selection: `closed` is an availability word and a
1403
+ * delivery word is a disclosure word. Neither writes the other dimension, so the
1404
+ * other dimension stays unwritten instead of borrowing a value from the resolution.
1405
+ */
1406
+ function capabilityOverrideWord(configuration, id, dimension) {
1407
+ const override = configuration.manual?.overrides?.[id]
1408
+ if (override === undefined || override === 'automatic') return undefined
1409
+ if (override === 'closed') return dimension === 'availability' ? 'closed' : undefined
1410
+ return dimension === 'disclosure' ? override : undefined
1411
+ }
1412
+
1413
+ /**
1414
+ * The words this session's record wrote for one object, and which layer wrote each.
1415
+ *
1416
+ * One (object, address) holds one value, so a dimension this object's own entry wrote is
1417
+ * never also its server's: the layer map names a server only for a dimension this entry
1418
+ * wrote nothing for. The words stay the record's own because both the value column and the
1419
+ * receipt state what was written; the receipt names the layer that wrote them.
1420
+ */
1421
+ function sessionWords(statement, capability) {
1422
+ const own = capabilityChoiceOf(statement, capability.id) ?? {}
1423
+ const serverId = capability.mcpServer === undefined ? undefined : `mcpServers.${capability.mcpServer}`
1424
+ const server = serverId === undefined ? {} : capabilityChoiceOf(statement, serverId) ?? {}
1425
+ const words = {}
1426
+ const layers = {}
1427
+ for (const dimension of ROW_DIMENSIONS) {
1428
+ // The session column states this object's own record: an entry of its own, or `inherit`
1429
+ // when the record wrote none. A dimension this entry wrote belongs to this address, so the
1430
+ // receipt states it as this row's word and names no layer — naming the server there would
1431
+ // have the receipt owe a dimension the value column says this row holds (§8.1b), while the
1432
+ // runtime precedence of a closed server belongs to behaviour and does not reach the page.
1433
+ if (own[dimension] !== undefined) {
1434
+ words[dimension] = own[dimension]
1435
+ continue
1436
+ }
1437
+ // A server that wrote this dimension answers the member's at runtime, so the receipt names
1438
+ // it; a server that wrote `automatic` deleted that dimension from its rule and decides no
1439
+ // member, which is why the receipt names it only for a word the rule actually holds.
1440
+ if (capability.kind !== 'server' && server[dimension] !== undefined && server[dimension] !== 'automatic') {
1441
+ layers[dimension] = serverId
1442
+ }
1443
+ }
1444
+ return { words, layers }
1445
+ }
1446
+
1447
+ /**
1448
+ * The session address's value for one object: the record's own words.
1449
+ *
1450
+ * Both columns state what their own record wrote, so a recorded `automatic` reads
1451
+ * `automatic` here — the word the record holds — and `inherit` is this column's word for
1452
+ * an address that recorded nothing, so no cell is blank. What those words would resolve
1453
+ * to is the runtime's answer and belongs to behaviour, not to this page: no resolved
1454
+ * value is put on screen. A dimension a server-level entry decided states that entry's
1455
+ * word and the receipt names the layer, which is where a delegation is readable.
1456
+ */
1457
+ function sessionChoice(capability, statement) {
1458
+ const { words } = sessionWords(statement, capability)
1459
+ return { availability: words.availability ?? 'inherit', disclosure: words.disclosure ?? 'inherit' }
1460
+ }
1461
+
1462
+ /**
1463
+ * The identity, name, category, description and proved source of one declared
1464
+ * capability.
1465
+ *
1466
+ * The panel's rows and the settings page's entries state the same supply facts,
1467
+ * so they take them from one place instead of two derivations that could drift.
1468
+ */
1469
+ function capabilityEntry(capability, presentation) {
1470
+ const display = presentation.get(capability.id)
1471
+ return {
1472
+ id: capability.id,
1473
+ name: display?.name ?? capability.name ?? capability.id,
1474
+ category: capability.category,
1475
+ description: capability.description,
1476
+ // Without a display entry the identity is stated by the namespace it belongs to:
1477
+ // a resource row is MCP's, a Skill row is a Skill's, and a tool row is the Host
1478
+ // registry's. A category is not a source, so it cannot stand in for one here.
1479
+ source: display?.source
1480
+ ?? (capability.mcpServer === undefined
1481
+ ? { kind: capability.category === 'skill' ? 'skill' : capability.category === 'resource' ? 'mcp' : 'host' }
1482
+ : { kind: 'mcp', server: capability.mcpServer }),
1483
+ }
1484
+ }
1485
+
1486
+ /**
1487
+ * v2 rows: one per declared capability, one per declared MCP server.
1488
+ *
1489
+ * `description` is the Host's own one-line contract for the capability it declared.
1490
+ * The panel states seven columns and none of them is this text; the `/capabilities
1491
+ * list` command still names what it found, and taking that line from the same row
1492
+ * keeps one supply read behind both surfaces.
1493
+ */
1494
+ function capabilityRows(capabilities, presentation, configuration, statement, proved) {
1495
+ const rows = capabilities.map(capability => {
1496
+ const { words, layers } = sessionWords(statement, capability)
1497
+ return {
1498
+ ...capabilityEntry(capability, presentation),
1499
+ kind: 'capability',
1500
+ saved: savedChoice(configuration, capability),
1501
+ session: sessionChoice(capability, statement),
1502
+ // What the record wrote, in its own words, and the layer that wrote it: the session and
1503
+ // receipt columns state these words, and the receipt names the layer that wrote them.
1504
+ record: { availability: words.availability, disclosure: words.disclosure,
1505
+ layers: { availability: layers.availability ?? null, disclosure: layers.disclosure ?? null } },
1506
+ evidence: proved.has(capability.id),
1507
+ }
1508
+ })
1509
+ for (const server of new Set(capabilities.map(item => item.mcpServer).filter(Boolean))) {
1510
+ const id = `mcpServers.${server}`
1511
+ const row = { id, kind: 'server', name: server, category: 'server', source: { kind: 'mcp', server } }
1512
+ const { words, layers } = sessionWords(statement, row)
1513
+ rows.push({
1514
+ ...row,
1515
+ saved: savedChoice(configuration, row),
1516
+ session: sessionChoice(row, statement),
1517
+ record: { availability: words.availability, disclosure: words.disclosure,
1518
+ layers: { availability: layers.availability ?? null, disclosure: layers.disclosure ?? null } },
1519
+ // A server is not a capability: no execution can obtain it as a whole.
1520
+ evidence: false,
1521
+ })
1522
+ }
1523
+ return rows
1524
+ }
1525
+
1526
+ /** v2 rows: one per supplied binding entry. A withheld entry has no row. */
1527
+ async function bindingRows(events) {
1528
+ const supply = typeof readUserBindings === 'function' ? await readUserBindings() : { entries: [] }
1529
+ if (supply === null || typeof supply !== 'object' || !Array.isArray(supply.entries) || supply.error !== undefined) {
1530
+ // A document this build cannot parse is a failed read, not an empty catalogue:
1531
+ // an empty table would tell the user they have no bindings.
1532
+ throw new Error('ptc-plus: the binding catalogue could not be read')
1533
+ }
1534
+ const adoption = bindingAdoptionOf(events)
1535
+ const declined = adoption.state === 'decided' ? new Set(adoption.adoption.declined) : new Set()
1536
+ return supply.entries.filter(entry => entry.enabled === true).map(entry => ({
1537
+ id: entry.id,
1538
+ kind: 'binding',
1539
+ name: entry.name ?? entry.id,
1540
+ category: 'binding',
1541
+ source: { kind: 'bindings' },
1542
+ // The bindings document is a binding's saved address: an entry is listed only
1543
+ // while it is enabled, and its model-context preference decides whether the
1544
+ // model reads the declaration from the first request or only the callable
1545
+ // contract on demand.
1546
+ saved: { availability: 'enabled',
1547
+ disclosure: bindingModelPreferences(entry.modelContext).includeDeclaration ? 'full' : 'on-demand' },
1548
+ // The adoption record names the entries this session declined, so an entry it
1549
+ // does not name has no receipt at that address.
1550
+ session: { use: adoption.state === 'unreadable' ? 'damaged' : declined.has(entry.id) ? 'skip' : 'use',
1551
+ recorded: adoption.state === 'unreadable' || declined.has(entry.id) },
1552
+ evidence: false,
1553
+ }))
1554
+ }
1555
+
1556
+ /**
1557
+ * This session's own log, reached through whichever public handle the Host has.
1558
+ *
1559
+ * A live session object, the agent that owns it, and the persistence query all
1560
+ * name the same log. None of them is a capability source: which handle answered
1561
+ * changes no rendered value, only whether the read had to wait for a query.
1562
+ */
1563
+ async function sessionLogOf(sessionId, signal) {
1564
+ const owned = typeof ctx.get === 'function' ? ctx.get('sessions')?.get?.(sessionId) : ctx.sessions?.get?.(sessionId)
1565
+ const holder = liveAgentFor(sessionId)
1566
+ const session = owned ?? holder?.session
1567
+ const query = sessionQueryService()
1568
+ // A Host without a projection service still has the session's own events.
1569
+ if (session !== undefined && session !== null && query === undefined && typeof session.snapshotEvents === 'function') {
1570
+ return { id: session.id, header: session.header, events: session.snapshotEvents() }
1571
+ }
1572
+ if (session !== undefined && session !== null) return readSessionLog(session, query, signal)
1573
+ return readSessionLog({ id: sessionId }, query, signal)
1574
+ }
1575
+
1576
+ /**
1577
+ * The live agent that holds one session's scope, when the Host still holds it.
1578
+ *
1579
+ * The agent is a scope handle and nothing else here: it names the declaration the
1580
+ * supply read observes. This lookup never asks whether the session is usable, runs
1581
+ * no maintenance branch and writes nothing — a session the Host no longer holds
1582
+ * simply has no scope left to name.
1583
+ */
1584
+ function liveAgentFor(sessionId) {
1585
+ return (ctx.agents?.list?.() ?? []).find(agent => String(agent.session?.id ?? agent.id) === sessionId)
1586
+ }
1587
+
1588
+ /**
1589
+ * One session catalogue's two sources: this session's own log and the supply it was
1590
+ * declared.
1591
+ *
1592
+ * The supply is read in the session's own scope while the Host still holds that
1593
+ * scope, because the Host's own plane is a different question and answers fewer rows
1594
+ * than the session had. Once the scope is gone, the face this session's recorded
1595
+ * plan froze is the only statement of what it was supplied. A session that recorded
1596
+ * no plan, or one whose plan was composed while no capability feature required an
1597
+ * inventory, has no supply this read can state, so the read fails and names the
1598
+ * record it needed instead of answering with a face the session never had. A failed
1599
+ * source fails the whole read; a partial answer would be a screen that silently
1600
+ * misses what exists.
1601
+ */
1602
+ async function sessionCatalogSupply(sessionId, signal) {
1603
+ signal?.throwIfAborted()
1604
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('ptc-plus: a capability read needs a session')
1605
+ let log
1606
+ try {
1607
+ log = await sessionLogOf(sessionId, signal)
1608
+ } catch (cause) {
1609
+ signal?.throwIfAborted()
1610
+ throw new Error('ptc-plus: the session catalogue needs this session\'s log', { cause })
1611
+ }
1612
+ if (log === null || typeof log !== 'object' || log.header === undefined) {
1613
+ throw new Error('ptc-plus: the session catalogue needs this session\'s header')
1614
+ }
1615
+ const events = Array.isArray(log.events) ? log.events : []
1616
+ const cwd = typeof log.header.cwd === 'string' ? log.header.cwd : undefined
1617
+ const agent = liveAgentFor(sessionId)
1618
+ signal?.throwIfAborted()
1619
+ let supply
1620
+ if (agent !== undefined) supply = await hostSupply({ agent, cwd }, signal)
1621
+ else {
1622
+ const recorded = recordedPlan(events, sessionId)
1623
+ if (recorded === undefined) {
1624
+ throw new Error('ptc-plus: this session recorded no capability plan; a session with no live scope states the face its record froze')
1625
+ }
1626
+ const snapshot = sourcePlan(recorded, sessionId)
1627
+ // A composition answers an empty `capabilities` two ways, and only one of them is a
1628
+ // face. When an inventory was required the array is the face, empty or not. When none
1629
+ // was — no capability feature was on — nothing was supplied to the model, so the record
1630
+ // states no face at all and an empty table would claim the Host declared nothing.
1631
+ if (!requiresCapabilityInventory(snapshot.settings)) {
1632
+ throw new Error('ptc-plus: this session\'s recorded plan froze no capability inventory; a session with no live scope states the face its record froze, and this record holds none')
1633
+ }
1634
+ supply = frozenSupply(snapshot)
1635
+ }
1636
+ signal?.throwIfAborted()
1637
+ return { events, supply }
901
1638
  }
902
1639
 
903
1640
  return Object.freeze({
@@ -906,184 +1643,158 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
906
1643
  stateFor,
907
1644
  visibleSchemas,
908
1645
  validateSupply,
909
- async refresh(agent, sections, signal, commandId) {
1646
+ /**
1647
+ * Recompose this session's plan from the saved configuration and the record.
1648
+ *
1649
+ * `agent` is a parameter of the call, not a property of the session: which
1650
+ * live agent's composition to replace is what the caller states by naming it.
1651
+ * Nothing else about the session decides whether a refresh may happen.
1652
+ */
1653
+ async refresh(agent, sections, signal) {
910
1654
  signal?.throwIfAborted()
1655
+ if (agent === undefined) throw new TypeError('ptc-plus: a capability refresh needs an agent')
911
1656
  if (typeof agent.runMaintenance !== 'function') throw new Error('ptc-plus: capability refresh requires the public agent maintenance boundary')
912
1657
  return agent.runMaintenance(async maintenanceSignal => {
913
- await prepare(agent, sections, false, maintenanceSignal)
1658
+ // The Host's declaration is observed first: a refresh composes from it, so a
1659
+ // composition that had to guess it would compose from less than the Host says.
914
1660
  const assembly = await ctx.systemPrompt.assemble({ agent, scope: agent, signal: maintenanceSignal })
915
1661
  signal?.throwIfAborted()
916
1662
  maintenanceSignal.throwIfAborted()
917
1663
  const preparationSignal = AbortSignal.any([signal, maintenanceSignal, agent.signal].filter(Boolean))
918
- const previous = stateFor(agent)?.snapshot
919
- return prepare(agent, stateFor(agent)?.hostSections ?? assembly.sections, true, preparationSignal,
920
- commandId === undefined ? undefined : {
921
- sourceSettings: capabilityConfiguration(currentConfig), sessionOverrides: previous?.sessionOverrides ?? {}, commandId,
922
- })
1664
+ return prepare(agent, sections ?? stateFor(agent)?.hostSections ?? assembly.sections, true, preparationSignal)
923
1665
  })
924
1666
  },
925
- async catalogObservation(agent, signal) {
926
- if (!canRefresh(agent)) throw new Error('ptc-plus: select an active PTC session')
927
- const assembly = await ctx.systemPrompt.assemble({ agent, scope: agent, signal, ptcPlusCapabilityObservation: true })
928
- const presentation = new Map()
929
- const capabilities = await inventory(agent, assembly.sections,
930
- { ...capabilityConfiguration(currentConfig), enabled: false, manual: { baseline: [], overrides: {} } }, signal, presentation)
931
- signal?.throwIfAborted()
932
- const history = await observeHistory(agent, signal)
933
- await reconcilePendingChoice(agent, signal)
934
- const recorded = recordedPlan(history, String(agent.session.id ?? agent.id))
935
- const snapshot = stateFor(agent)?.snapshot ?? (recorded && sourcePlan(recorded, String(agent.session.id ?? agent.id)))
936
- const state = snapshot ? { snapshot } : undefined
937
- const sourceSettings = snapshot?.sourceSettings ?? snapshot?.settings
938
- const sourceOverrides = sourceSettings?.manual?.overrides
939
- const sessionSourceSelection = id => sourceOverrides?.[id] ?? (sourceOverrides ? 'automatic' : 'unknown')
940
- // A paused or unmanaged session has no frozen allocation to report: every
941
- // row then states the one truth that applies, the Host default. Only a
942
- // managed session may report a pending refresh boundary.
943
- const planManaged = state !== undefined && effectiveCapabilitySettings(state.snapshot.settings).enabled === true
944
- const management = state === undefined ? 'unknown'
945
- : state.snapshot.settings.contextEnabled === false ? 'paused'
946
- : planManaged ? 'managed' : 'off'
947
- const dimensions = capability => {
948
- const saved = capabilityConfiguration(currentConfig)
949
- if (!saved.policy && !snapshot?.settings.policy) return { managementEnabled: snapshot && effectiveCapabilitySettings(snapshot.settings).enabled }
950
- const source = sourceSettings?.policy ? capabilityRule(sourceSettings.policy, capability) : undefined
951
- const temporary = snapshot?.sessionOverrides?.[capability.id]
952
- const rule = rules => capability.id.startsWith('mcpServers.') ? rules.mcpServers[capability.id.slice(11)] ?? {} : rules.items[capability.id] ?? {}
953
- return { managementEnabled: snapshot && effectiveCapabilitySettings(snapshot.settings).enabled, ...(saved.policy ? { savedRule: rule(saved.policy) } : {}),
954
- ...(sourceSettings?.policy ? { sourceRule: rule(sourceSettings.policy), sourceDimensions: source } : {}),
955
- temporaryRule: temporaryCapabilityRule(temporary),
956
- ...(snapshot?.settings.policy ? { effectiveDimensions: snapshot.plan.entries.find(entry => entry.id === capability.id)
957
- ?? (capability.category === 'server' ? effectiveCapabilitySettings(snapshot.settings).enabled
958
- ? capabilityRule(snapshot.settings.policy, capability) : { availability: 'enabled', disclosure: 'host', availabilitySource: 'host', disclosureSource: 'host' } : undefined) } : {}) }
959
- }
960
- const entries = capabilities.map(capability => ({ id: capability.id, category: capability.category,
961
- description: capability.description, ...presentation.get(capability.id), ...dimensions(capability),
962
- ...(capability.mcpServer === undefined ? {} : { mcpServer: capability.mcpServer }),
963
- saved: capabilityConfiguration(currentConfig).manual.overrides[capability.id] ?? 'automatic',
964
- sessionSourceSelection: sessionSourceSelection(capability.id),
965
- temporary: typeof state?.snapshot.sessionOverrides?.[capability.id] === 'object' ? 'inherit' : state?.snapshot.sessionOverrides?.[capability.id] ?? 'inherit',
966
- effective: state === undefined ? 'pending'
967
- : planManaged ? state.snapshot.plan.entries.find(entry => entry.id === capability.id)?.selection ?? 'pending'
968
- : 'host' }))
969
- for (const server of new Set(capabilities.map(item => item.mcpServer).filter(Boolean))) {
970
- const id = `mcpServers.${server}`
971
- const enclosingClosure = state ? effectiveCapabilitySettings(state.snapshot.settings).enabled && (state.snapshot.settings.policy
972
- ? state.snapshot.settings.policy.mcpServers[server]?.availability === 'closed'
973
- : state.snapshot.settings.manual.overrides[id] === 'closed') : undefined
974
- const effective = state === undefined ? 'pending'
975
- : !planManaged ? 'host'
976
- : state.snapshot.settings.policy
977
- ? capabilityRule(state.snapshot.settings.policy, { id, category: 'server', mcpServer: server }).availability
978
- : enclosingClosure ? 'closed' : 'enabled'
979
- entries.push({ id, category: 'server', name: server, description: `MCP server ${server}`,
980
- source: { kind: 'mcp', server }, ...dimensions({ id, category: 'server', mcpServer: server }), saved: capabilityConfiguration(currentConfig).manual.overrides[id] ?? 'automatic',
981
- sessionSourceSelection: sessionSourceSelection(id),
982
- temporary: typeof state?.snapshot.sessionOverrides?.[id] === 'object' ? 'inherit' : state?.snapshot.sessionOverrides?.[id] ?? 'inherit',
983
- enclosingClosure, effective })
984
- }
985
- return { management, entries }
986
- },
987
- async catalogFor(agent, signal) {
988
- return (await this.catalogObservation(agent, signal)).entries
989
- },
1667
+ /**
1668
+ * State one capability choice: one intent, one writer, one receipt.
1669
+ *
1670
+ * A session choice is a record and a global choice is a saved setting; neither
1671
+ * is a plan. The plan is the runtime's composition of the record and is
1672
+ * rebuilt by the entry that composes one, so no control here asks whether a
1673
+ * runtime exists — a decision this session made is the same decision whether
1674
+ * or not something is running to act on it.
1675
+ */
990
1676
  async change(agent, intent, signal, globalSettings) {
991
- if (!canRefresh(agent)) throw new Error('ptc-plus: select an active PTC session')
992
1677
  const dimension = intent.dimension
993
- const choices = dimension === 'availability' ? ['enabled', 'closed', 'automatic', 'inherit']
1678
+ const selections = dimension === 'availability' ? ['enabled', 'closed', 'automatic', 'inherit']
994
1679
  : dimension === 'disclosure' ? [...CAPABILITY_DISCLOSURES, 'automatic', 'inherit']
995
1680
  : dimension === undefined ? ['full', 'on-demand', 'closed', 'automatic', 'inherit'] : []
996
- if (!['session', 'global'].includes(intent.scope) || !choices.includes(intent.selection)
1681
+ if (!['session', 'global'].includes(intent.scope) || !selections.includes(intent.selection)
997
1682
  || intent.scope === 'global' && intent.selection === 'inherit') throw new TypeError('invalid capability change')
998
- if (typeof agent.runMaintenance !== 'function') throw new Error('ptc-plus: capability changes require public agent maintenance')
999
- return agent.runMaintenance(async maintenanceSignal => {
1000
- const combined = AbortSignal.any([signal, maintenanceSignal, agent.signal].filter(Boolean))
1001
- combined.throwIfAborted()
1002
- await prepare(agent, undefined, false, combined)
1003
- const assembly = await ctx.systemPrompt.assemble({ agent, scope: agent, signal: combined, ptcPlusCapabilityObservation: true })
1004
- const completeSettings = { ...capabilityConfiguration(currentConfig), enabled: false, manual: { baseline: [], overrides: {} } }
1005
- const available = await inventory(agent, assembly.sections, completeSettings, combined)
1006
- if (intent.id.startsWith('mcpServers.')) {
1007
- const server = intent.id.slice('mcpServers.'.length)
1008
- if (!available.some(item => item.mcpServer === server)) throw new Error('unknown MCP server in this session')
1009
- if (!dimension && !['closed', 'automatic', 'inherit'].includes(intent.selection)) throw new Error('MCP server choices are closed, automatic or inherit')
1010
- } else if (!available.some(item => item.id === intent.id)) throw new Error('unknown managed capability in this session')
1011
- const previous = stateFor(agent)?.snapshot
1012
- let sourceSettings = previous?.sourceSettings ?? previous?.settings ?? capabilityConfiguration(currentConfig)
1013
- if (dimension && !sourceSettings.policy) {
1014
- sourceSettings = capabilityConfiguration(capabilityProfileConfiguration(savedConfiguration(sourceSettings)))
1015
- }
1016
- if (!effectiveCapabilitySettings(sourceSettings).enabled || !effectiveCapabilitySettings(capabilityConfiguration(currentConfig)).enabled) throw new Error('ptc-plus: enable capability management before changing a session choice')
1017
- const sessionOverrides = { ...previous?.sessionOverrides }
1018
- let globalSaved = false
1019
- if (intent.scope === 'session') {
1020
- if (dimension || sourceSettings.policy) {
1021
- const patch = temporaryCapabilityRule(sessionOverrides[intent.id])
1022
- if (dimension) {
1023
- if (intent.selection === 'inherit') delete patch[dimension]
1024
- else patch[dimension] = intent.selection
1025
- } else if (intent.selection === 'inherit') {
1026
- delete patch.availability; delete patch.disclosure
1027
- } else if (intent.selection === 'closed') patch.availability = 'closed'
1028
- else Object.assign(patch, temporaryCapabilityRule(intent.selection))
1029
- if (Object.keys(patch).length) sessionOverrides[intent.id] = patch
1030
- else delete sessionOverrides[intent.id]
1031
- } else if (intent.selection === 'inherit') delete sessionOverrides[intent.id]
1032
- else sessionOverrides[intent.id] = intent.selection
1033
- } else {
1034
- if (!globalSettings?.read || !globalSettings?.update) throw new Error('ptc-plus: global capability changes require the public settings writer')
1035
- const before = globalSettings.read()
1036
- if (!before || !Number.isSafeInteger(before.revision)) throw new Error('ptc-plus: global capability changes require settings revision evidence')
1037
- if (!effectiveCapabilitySettings(capabilityConfiguration({ ...currentConfig, ...before.value })).enabled) throw new Error('ptc-plus: enable capability management before changing a global choice')
1038
- const overrides = JSON.parse(before.value.capabilityOverrides ?? '{}')
1039
- if (intent.selection === 'automatic') delete overrides[intent.id]
1040
- else overrides[intent.id] = intent.selection
1041
- combined.throwIfAborted()
1042
- const policy = capabilityPolicyForConfig(before.value)
1043
- let nextPolicy = dimension ? changeCapabilityRule(policy, intent.id, dimension, intent.selection)
1044
- : changeCapabilityRule(policy, intent.id, 'availability', intent.selection === 'closed' ? 'closed'
1045
- : intent.selection === 'automatic' ? 'automatic' : 'enabled')
1046
- if (!dimension) nextPolicy = intent.selection === 'automatic' ? changeCapabilityRule(nextPolicy, intent.id, 'disclosure', 'automatic')
1047
- : intent.selection === 'closed' ? nextPolicy : changeCapabilityRule(nextPolicy, intent.id, 'disclosure', intent.selection)
1048
- const patch = before.value.capabilityPolicy || dimension
1049
- ? { capabilityPolicy: JSON.stringify(nextPolicy), ...(!dimension ? { capabilityOverrides: JSON.stringify(overrides) } : {}) }
1050
- : { capabilityOverrides: JSON.stringify(overrides) }
1051
- await globalSettings.update(patch, before.revision)
1052
- globalSaved = true
1053
- }
1054
- try {
1055
- if (globalSaved) {
1056
- const saved = globalSettings.read()
1057
- if (!saved?.value || !Number.isSafeInteger(saved.revision)) throw new Error('ptc-plus: saved settings could not be confirmed for this session')
1058
- sourceSettings = capabilityConfiguration({ ...currentConfig, ...saved.value })
1059
- if (dimension) {
1060
- const retained = temporaryCapabilityRule(sessionOverrides[intent.id]); delete retained[dimension]
1061
- if (Object.keys(retained).length) sessionOverrides[intent.id] = retained
1062
- else delete sessionOverrides[intent.id]
1063
- } else delete sessionOverrides[intent.id]
1064
- }
1065
- combined.throwIfAborted()
1066
- if (!effectiveCapabilitySettings(sourceSettings).enabled) throw new Error('ptc-plus: enable capability management before changing a session choice')
1067
- await prepare(agent, assembly.sections, true, combined, { sourceSettings, sessionOverrides, commandId: intent.commandId })
1068
- return { globalSaved, sessionApplied: true, publication: planCommitted(agent, stateFor(agent).message) ? 'accepted' : 'pending' }
1069
- } catch (error) {
1070
- if (globalSaved) return { globalSaved: true, sessionApplied: false, error: String(error?.message ?? error) }
1071
- throw error
1072
- }
1073
- })
1683
+ // The record vocabulary is asked once, here, before anything is appended: an id it
1684
+ // cannot name would otherwise reach the message writer and fail there as an
1685
+ // internal error, after the command had already been answered.
1686
+ if (typeof intent.id !== 'string' || !CAPABILITY_ENTRY_ID.test(intent.id)) {
1687
+ throw new TypeError('ptc-plus: a capability choice names an entry as tools.<name>, skills.<name>, mcp.<server> or mcpServers.<server>')
1688
+ }
1689
+ // A server has no disclosure of its own: its members carry one. This states
1690
+ // the vocabulary, not what any process happens to supply.
1691
+ if (!dimension && intent.id.startsWith('mcpServers.') && !['closed', 'automatic', 'inherit'].includes(intent.selection)) {
1692
+ throw new Error('ptc-plus: MCP server choices are closed, automatic or inherit')
1693
+ }
1694
+ if (intent.scope === 'global') {
1695
+ if (!globalSettings?.read || !globalSettings?.update) throw new Error('ptc-plus: global capability changes require the public settings writer')
1696
+ const before = globalSettings.read()
1697
+ if (!before || !Number.isSafeInteger(before.revision)) throw new Error('ptc-plus: global capability changes require settings revision evidence')
1698
+ const overrides = JSON.parse(before.value.capabilityOverrides ?? '{}')
1699
+ if (intent.selection === 'automatic') delete overrides[intent.id]
1700
+ else overrides[intent.id] = intent.selection
1701
+ signal?.throwIfAborted()
1702
+ // The settings document this writer reads holds the record: a document carrying a policy
1703
+ // is composed into verbatim. Only a document that carries none starts from the record's
1704
+ // own layers, so the synthesized bottom layer is never frozen into the record (§8.1c).
1705
+ const policy = before.value.capabilityPolicy !== undefined
1706
+ ? capabilityPolicyForConfig(before.value) : capabilityPolicyFromRecord(before.value)
1707
+ let nextPolicy = dimension ? changeCapabilityRule(policy, intent.id, dimension, intent.selection)
1708
+ : changeCapabilityRule(policy, intent.id, 'availability', intent.selection === 'closed' ? 'closed'
1709
+ : intent.selection === 'automatic' ? 'automatic' : 'enabled')
1710
+ if (!dimension) nextPolicy = intent.selection === 'automatic' ? changeCapabilityRule(nextPolicy, intent.id, 'disclosure', 'automatic')
1711
+ : intent.selection === 'closed' ? nextPolicy : changeCapabilityRule(nextPolicy, intent.id, 'disclosure', intent.selection)
1712
+ const patch = before.value.capabilityPolicy || dimension
1713
+ ? { capabilityPolicy: JSON.stringify(nextPolicy), ...(!dimension ? { capabilityOverrides: JSON.stringify(overrides) } : {}) }
1714
+ : { capabilityOverrides: JSON.stringify(overrides) }
1715
+ await globalSettings.update(patch, before.revision)
1716
+ return { globalSaved: true, sessionApplied: true, publication: 'recorded' }
1717
+ }
1718
+ // A statement is the whole of what this session decided, so composing the next
1719
+ // one starts from the log that holds the current statement — the same log the
1720
+ // panel reads. A stale or empty runtime view would silently restate the choice
1721
+ // just made and withdraw every decision recorded before it.
1722
+ const statement = capabilitySelectionOf(await observeHistory(agent, signal), String(agent.session.id ?? agent.id))
1723
+ signal?.throwIfAborted()
1724
+ const choices = { ...(statement.state === 'decided' ? statement.selection.choices : {}) }
1725
+ const patch = { ...(choices[intent.id] ?? {}) }
1726
+ if (dimension) {
1727
+ if (intent.selection === 'inherit') delete patch[dimension]
1728
+ else patch[dimension] = intent.selection
1729
+ } else if (intent.selection === 'inherit') {
1730
+ delete patch.availability; delete patch.disclosure
1731
+ } else if (intent.selection === 'closed') patch.availability = 'closed'
1732
+ else Object.assign(patch, capabilityRuleOfSelection(intent.selection))
1733
+ if (Object.keys(patch).length > 0) choices[intent.id] = patch
1734
+ else delete choices[intent.id]
1735
+ const publication = recordSelection(agent, choices, signal)
1736
+ return { globalSaved: false, sessionApplied: true, publication }
1074
1737
  },
1738
+ /**
1739
+ * The composition this session built but has not delivered yet.
1740
+ *
1741
+ * A command that composed a plan before the Host accepted a step has to carry it
1742
+ * in its receipt: the plan is not in the log yet, so a restart would otherwise
1743
+ * have no way to know which composition the command made.
1744
+ */
1075
1745
  pendingChoiceMessage(agent) {
1076
1746
  const state = stateFor(agent)
1077
- return ['ptc-capability-environment/v2', 'ptc-capability-environment/v3', 'ptc-capability-environment/v4'].includes(state?.snapshot.schema) && !planCommitted(agent, state.message) ? state.message : undefined
1747
+ return state?.message !== undefined && !planCommitted(agent, state.message) ? state.message : undefined
1078
1748
  },
1079
- projectSections(agent, sections) {
1749
+ /**
1750
+ * The sections one assembly delivers.
1751
+ *
1752
+ * `delivered` is the step's admitted definitions, as in `sdk`: the declaration, the
1753
+ * program members and the explorable metadata all state that set, so guidance that
1754
+ * instructs a capability must state it too. A composition is not the admission — it
1755
+ * is composed once and reused until the session decides something else — so a
1756
+ * section whose definition the step withholds would otherwise stay after the
1757
+ * capability it describes has left every other surface.
1758
+ *
1759
+ * The resource directory follows the same answer through the rows it names: its servers
1760
+ * are the argument of the three resource operations, so a row that states no delivered
1761
+ * operation is no row of this step and the directory lists no server for it.
1762
+ */
1763
+ projectSections(agent, sections, delivered = undefined) {
1080
1764
  const state = stateFor(agent)
1081
- if (!state?.snapshot.plan.enabled) return sections
1765
+ const decisions = decisionsFor(agent)
1766
+ const admitted = delivered === undefined ? undefined : new Set(delivered.map(schema => schema.name))
1767
+ // The guidance attached to a capability the session closed goes with the capability,
1768
+ // whether or not a composition states the rest of the environment. Dependency
1769
+ // sections are named after what they belong to, so the record decides them directly.
1770
+ // `tool:<name>` names a capability of this step, so the admission decides it as well;
1771
+ // the two transports are the call channel rather than capabilities and stay exempt.
1772
+ const declared = sections.filter(section => {
1773
+ const name = section.name
1774
+ if (typeof name !== 'string') return true
1775
+ if (name.startsWith('tool:')) {
1776
+ const member = name.slice('tool:'.length)
1777
+ if (TRANSPORTS.has(member)) return true
1778
+ if (admitted !== undefined && !admitted.has(member)) return false
1779
+ return !recordClosesMember(decisions, member)
1780
+ }
1781
+ if (name.startsWith('mcp:') && name !== 'mcp-resource-servers') return !decisions.servers.has(name.slice('mcp:'.length))
1782
+ return true
1783
+ })
1784
+ if (!state?.snapshot.plan.enabled) {
1785
+ return declared.flatMap(section => recordKeptServers(decisions, section,
1786
+ sharedPaths(state, admitted, decisions).operations.length !== 0))
1787
+ }
1788
+ const paths = sharedPaths(state, admitted, decisions)
1082
1789
  const inventory = [...declaredCapabilities(state).values()]
1083
- const retained = inventory.filter(item => declaredSelection(state, item.id) === 'host' && supplied(state, item.id))
1790
+ // A resource row is explorable through the operations its contract names, so it is a
1791
+ // row of this step only while one of them is delivered; a Skill row keeps its own
1792
+ // answer because it may still state the user's path when the loader is withheld.
1793
+ const retained = inventory.filter(item => declaredSelection(state, item.id) === 'host' && supplied(state, item.id)
1794
+ && (item.category !== CAPABILITY_INVENTORY_CATEGORIES.resource || paths.operations.length > 0))
1084
1795
  const retainedSections = new Set(retained.flatMap(item => item.dependencies ?? []).map(item => item.id))
1085
1796
  const ownedSections = new Set(inventory.flatMap(item => item.dependencies ?? []).map(item => item.id))
1086
- return sections.flatMap(section => {
1797
+ return declared.flatMap(section => {
1087
1798
  if (section.name === 'mcp-resource-servers') {
1088
1799
  const servers = retained.filter(item => item.category === 'resource').map(item => item.selector.server)
1089
1800
  return servers.length ? [{ ...section, text: `Use the following MCP resource servers as the server argument: ${JSON.stringify(servers)}.` }] : []
@@ -1094,32 +1805,88 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
1094
1805
  return [section]
1095
1806
  })
1096
1807
  },
1097
- sdk(agent, nativeSdk) {
1808
+ /**
1809
+ * The declarations one assembly delivers.
1810
+ *
1811
+ * `delivered` is the step's admitted definitions — what the Host rendered its own
1812
+ * `tools:sdk` block from and what it builds the program's `tools` members from. A
1813
+ * declaration computed from the registry view instead would name a member this step
1814
+ * does not deliver, which the program then cannot call. Absent an observation, the
1815
+ * registry view is the only evidence there is.
1816
+ *
1817
+ * The edit transport is read from the registry either way: it is this plugin's own
1818
+ * installed tool and stays declared in every managed render, including the step on
1819
+ * which it was installed — an admission resolved before that install cannot state it.
1820
+ */
1821
+ sdk(agent, nativeSdk, delivered = undefined) {
1098
1822
  const state = stateFor(agent)
1099
- if (!state?.snapshot.plan.enabled) return nativeSdk
1823
+ const decisions = decisionsFor(agent)
1100
1824
  const candidates = toolSchemasForAgent(agent)
1101
- const schemas = candidates.filter(schema => !TRANSPORTS.has(schema.name))
1102
- const host = schemas.filter(schema => declaredSelection(state, `tools.${schema.name}`) === 'host'
1825
+ // The admission is a restriction of this view of the registry, so one registry read
1826
+ // answers both: which definitions exist, and which of them this step delivers.
1827
+ const admitted = delivered === undefined ? undefined : new Set(delivered.map(schema => schema.name))
1828
+ const reached = admitted === undefined ? candidates : candidates.filter(schema => admitted.has(schema.name))
1829
+ const declared = reached.filter(schema => !recordClosesMember(decisions, schema.name))
1830
+ const unchanged = declared.length === reached.length
1831
+ if (!state?.snapshot.plan.enabled) {
1832
+ // No composition to state the environment: the Host's own text is the delivered
1833
+ // declaration, and the record's closures are the only thing it may not carry.
1834
+ return unchanged ? nativeSdk : renderManagedSdk(candidates, declared)
1835
+ }
1836
+ const members = declared.filter(schema => !TRANSPORTS.has(schema.name))
1837
+ const host = members.filter(schema => declaredSelection(state, `tools.${schema.name}`) === 'host'
1103
1838
  && memberSupplied(state, schema.name))
1104
- if (host.length === schemas.length) return nativeSdk
1105
- // The edit transport is callable inside every program and is not part of
1106
- // the managed disclosure, so its Host schema stays declared in every
1107
- // managed render. The direct transport is never an SDK member.
1108
- const editTransport = candidates.find(schema => schema.name === EDIT_TRANSPORT)
1109
- return renderToolsSdk(editTransport === undefined ? host : [...host, editTransport])
1839
+ // The edit transport is callable inside every program and is not part of the
1840
+ // managed disclosure, so its Host schema stays declared in every managed render.
1841
+ // The direct transport is never an SDK member.
1842
+ if (unchanged && host.length === members.length) return nativeSdk
1843
+ return renderManagedSdk(candidates, host)
1110
1844
  },
1111
1845
  projectMembers(agent, members) {
1112
1846
  const state = stateFor(agent)
1113
- if (!state?.snapshot.plan.enabled) return members
1114
- return members.filter(name => memberSupplied(state, name))
1847
+ const decisions = decisionsFor(agent)
1848
+ const declared = members.filter(name => !recordClosesMember(decisions, name))
1849
+ if (!state?.snapshot.plan.enabled) return declared
1850
+ return declared.filter(name => memberSupplied(state, name))
1115
1851
  },
1116
- metadata(agent) {
1852
+ /**
1853
+ * The capabilities this session can explore.
1854
+ *
1855
+ * A row states an invocation path, and a composition states the one it was built under. A
1856
+ * decision recorded afterwards is already the session's newest statement — `change` writes a
1857
+ * record, never a plan — so a row whose path that decision took away may state only what
1858
+ * remains: a shared loader's Skill row keeps the user path or leaves entirely, and a
1859
+ * resource row states only the operations this session still delivers. Serving the frozen
1860
+ * path would hand the model a call the program no longer has, in the one window where the
1861
+ * declaration and the program members already answer the decision.
1862
+ *
1863
+ * `delivered` is the step's admitted definitions, as in `sdk` and `projectSections`, and it
1864
+ * asks the same question of the other input a composition cannot answer: a composition is
1865
+ * built once and reused while a step may deliver less — this session decided nothing, so
1866
+ * nothing recomposes — and every other projection already states that step. The shared
1867
+ * operations a row can name are exactly the ones this step delivers, so a row whose loader
1868
+ * or operation the step withholds states the path that remains instead of a call the
1869
+ * program lacks. Absent an observation the composition's answer stands: a caller with no
1870
+ * admission is not asking to state less than the session was composed from.
1871
+ *
1872
+ * A row only ever loses a path here. Absence of evidence is not an addition: an operation
1873
+ * the plan never froze is not one this composition stated, so no row starts stating it.
1874
+ */
1875
+ metadata(agent, delivered = undefined) {
1117
1876
  const state = stateFor(agent)
1118
1877
  if (!state?.snapshot.plan.enabled) return []
1878
+ const policyLayer = Boolean(effectiveCapabilitySettings(state.snapshot.settings)?.policy)
1879
+ const admitted = delivered === undefined ? undefined : new Set(delivered.map(schema => schema.name))
1880
+ const paths = sharedPaths(state, admitted, decisionsFor(agent))
1119
1881
  const capabilities = declaredCapabilities(state)
1120
1882
  return ['skill', 'resource'].map(category => ({ namespace: category === 'skill' ? 'skills' : 'mcp',
1121
1883
  members: [...capabilities.values()].filter(item => item.category === category && supplied(state, item.id))
1122
1884
  .map(item => state.liveCapabilities?.get(item.id) ?? item)
1885
+ .flatMap(item => category === 'skill'
1886
+ ? [paths.loader ? item : retainedSkillCapability(item, false)]
1887
+ : paths.operations.length === 0 ? []
1888
+ : [{ ...item, contract: `MCP resource server ${JSON.stringify(item.selector.server)}. ${resourceOperationNotice(paths.operations, policyLayer)}` }])
1889
+ .filter(item => item.effectiveAvailability !== 'closed')
1123
1890
  .map(item => ({ name: item.id.slice(item.id.indexOf('.') + 1), description: item.description,
1124
1891
  contract: item.contract, dependencies: item.dependencies ?? [], revision: item.revision, parameters: {}, returns: {},
1125
1892
  ...(item.invocation === undefined ? {} : { invocation: item.invocation }),
@@ -1153,7 +1920,6 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
1153
1920
  const decision = await next()
1154
1921
  if (decision.kind !== 'enter' || payload.signal?.aborted) return decision
1155
1922
  await observeHistory(payload.agent, payload.signal)
1156
- await reconcilePendingChoice(payload.agent, payload.signal, true)
1157
1923
  const state = stateFor(payload.agent)
1158
1924
  if (decision.kind !== 'enter' || !state || payload.signal?.aborted) return decision
1159
1925
  await statisticsOperation(() => store?.available(payload.agent, statisticsSnapshot(state)))
@@ -1241,7 +2007,11 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
1241
2007
  }
1242
2008
  // Before task history exists, the initial plan precedes admitted task messages.
1243
2009
  // Refresh and recovery append after committed history; they never reorder it.
1244
- const initial = !sessionEvents(payload.agent.session).some(event => ['user/message', 'assistant/message', 'tool/result'].includes(event.type))
2010
+ // A PTC-owned record is this plugin's own statement, not task history: a session
2011
+ // that recorded a choice before its first step still has no task to follow.
2012
+ const initial = !sessionEvents(payload.agent.session).some(event => event.type === 'assistant/message'
2013
+ || event.type === 'tool/result'
2014
+ || event.type === 'user/message' && !isPtcMessageSource(event.data?.source))
1245
2015
  return { ...decision, messages: initial ? [...additions, ...messages] : [...messages, ...additions] }
1246
2016
  },
1247
2017
  admitted(exec) {
@@ -1307,86 +2077,68 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
1307
2077
  member === agent ? status : member.status))
1308
2078
  }
1309
2079
  },
1310
- /**
1311
- * The saved, delivered and usable facts of one capability. A caller that
1312
- * only has "the save succeeded" must not report more than this returns.
1313
- */
1314
- describeCapabilityProof(agent, id) {
1315
- if (typeof id !== 'string' || id.length === 0) throw new TypeError('ptc-plus: capability proof requires a capability id')
1316
- const state = stateFor(agent)
1317
- const sourceSettings = state?.snapshot.sourceSettings ?? state?.snapshot.settings
1318
- ?? capabilityConfiguration(currentConfig)
1319
- // A global choice is recorded either as a legacy override or as a rule in the
1320
- // policy this owner writes for `/capabilities set global`, so both spellings
1321
- // prove the same save.
1322
- const capability = state?.snapshot.capabilities.find(item => item.id === id)
1323
- const recorded = capability === undefined || sourceSettings.policy === undefined
1324
- ? undefined : capabilityRule(sourceSettings.policy, capability)
1325
- const saved = Object.hasOwn(sourceSettings.manual?.overrides ?? {}, id)
1326
- || recorded?.availabilitySource === `item:${id}`
1327
- || recorded?.disclosureSource === `item:${id}`
1328
- // Delivery is a statement about the model-visible catalog. A plan that holds an
1329
- // entry whose choice is closed describes an entry the catalog does not list, so
1330
- // holding it proves nothing was delivered.
1331
- const entry = state?.snapshot.plan.entries.find(item => item.id === id)
1332
- const listed = entry !== undefined && entry.selection !== 'closed'
1333
- const delivery = !listed ? 'absent'
1334
- : state.message === undefined || planCommitted(agent, state.message) ? 'delivered' : 'pending'
1335
- return normalizeCapabilityProof({
1336
- schema: 'ptc-capability-proof/v1',
1337
- id,
1338
- saved,
1339
- delivery,
1340
- // Only a completed execution in this session proves usability; the
1341
- // worker's installed schemas and the published catalog prove nothing
1342
- // about what a cell actually obtained.
1343
- usability: state?.used?.has(id) === true ? 'proven' : 'unproven',
1344
- })
1345
- },
1346
2080
  describe(agent) {
1347
2081
  const state = stateFor(agent)
1348
- return state === undefined ? { configured: capabilityConfiguration(currentConfig), effective: null }
2082
+ // The session column is the record's answer, not the composition's: a
2083
+ // composition reports what it built, and this field reports what the session
2084
+ // decided. They agree once the composition has caught up, and until then only
2085
+ // this field is the answer.
2086
+ const sessionOverrides = sessionOverridesOf(capabilitySelectionOf(sessionEvents(agent?.session), agent?.session === undefined ? undefined : String(agent.session.id ?? agent.id)))
2087
+ return state === undefined ? { configured: capabilityConfiguration(currentConfig), effective: null, sessionOverrides }
1349
2088
  : { configured: capabilityConfiguration(currentConfig), effective: state.snapshot.plan,
1350
2089
  publication: state.message === undefined ? 'not-required' : planCommitted(agent, state.message) ? 'accepted' : 'pending',
1351
2090
  sourceSettings: state.snapshot.sourceSettings ?? state.snapshot.settings,
1352
- sessionOverrides: state.snapshot.sessionOverrides ?? {},
2091
+ sessionOverrides,
1353
2092
  pendingRefresh: digest(capabilityConfiguration(currentConfig)) !== digest(state.snapshot.sourceSettings ?? state.snapshot.settings) }
1354
2093
  },
1355
2094
  describeSessions() {
1356
2095
  return [...sessions].map(([sessionId, state]) => ({ sessionId, effective: state.snapshot.plan,
1357
2096
  publication: state.message === undefined ? 'not-required' : planCommitted({ session: state.session }, state.message) ? 'accepted' : 'pending',
1358
2097
  configuration: savedConfiguration(state.snapshot.sourceSettings ?? state.snapshot.settings),
1359
- sessionOverrides: state.snapshot.sessionOverrides ?? {},
2098
+ sessionOverrides: sessionOverridesOf(capabilitySelectionOf(sessionEvents(state.session), sessionId)),
1360
2099
  pendingRefresh: digest(capabilityConfiguration(currentConfig)) !== digest(state.snapshot.sourceSettings ?? state.snapshot.settings) }))
1361
2100
  },
1362
- canRefresh,
2101
+ /**
2102
+ * The live sessions a refresh could be requested for.
2103
+ *
2104
+ * A refresh replaces one live agent's composition, so the agent is the only
2105
+ * fact a target has to supply. Whether a session's model tool table declares
2106
+ * the direct transport answers a different question — what that session can
2107
+ * call — and a target list is not the place to answer it.
2108
+ */
1363
2109
  describeTargets() {
1364
- return (ctx.agents?.list?.() ?? []).filter(canRefresh)
1365
- .map(agent => ({ sessionId: String(agent.session.id ?? agent.id) }))
2110
+ return (ctx.agents?.list?.() ?? []).map(agent => ({ sessionId: String(agent.session.id ?? agent.id) }))
1366
2111
  },
1367
2112
  async describeSessionCatalog({ sessionId } = {}, signal) {
2113
+ // Three permanent sources, no fourth. The session's own log is the record, the
2114
+ // supply of this session's own plane is the declared face, and the saved
2115
+ // configuration is the saved column. A request that names no session states the
2116
+ // Host's own declaration — the process-scope object set, not a session's full
2117
+ // declared face, which also carries a preset's agent-scope entries (capability-surface
2118
+ // §8.9) — and no session record exists to state, so those columns read `inherit` and
2119
+ // no binding adoption is drawn from a log this read was not given.
2120
+ const named = typeof sessionId === 'string' && sessionId.length > 0
2121
+ const { events, supply } = named ? await sessionCatalogSupply(sessionId, signal)
2122
+ : { events: [], supply: await hostSupply({}, signal) }
2123
+ const configuration = capabilityConfiguration(currentConfig)
2124
+ const rows = capabilityRows(supply.capabilities, supply.presentation, configuration,
2125
+ capabilitySelectionOf(events, sessionId), capabilityProofOf(events))
2126
+ const bindingsEnabled = currentConfig.userBindingsEnabled === true
2127
+ if (bindingsEnabled && named) rows.push(...await bindingRows(events))
1368
2128
  signal?.throwIfAborted()
1369
- if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('ptc-plus: select an active PTC session')
1370
- const agent = (ctx.agents?.list?.() ?? []).find(candidate =>
1371
- String(candidate.session.id ?? candidate.id) === sessionId && canRefresh(candidate))
1372
- if (!agent) throw new TypeError('ptc-plus: select an active PTC session')
1373
- const { management, entries } = await this.catalogObservation(agent, signal)
1374
- signal?.throwIfAborted()
1375
- if (!(ctx.agents?.list?.() ?? []).includes(agent) || !canRefresh(agent)
1376
- || String(agent.session.id ?? agent.id) !== sessionId) {
1377
- throw new Error('ptc-plus: capability observation target is no longer available')
1378
- }
1379
- const savedSettings = capabilityConfiguration(currentConfig)
1380
- // The saved group and management switches gate session writes in change();
1381
- // report them so the Client can block staged choices with the reason.
1382
- return { schema: 'ptc-capability-session-catalog/v1', management, entries,
1383
- savedSettings: { enabled: effectiveCapabilitySettings(savedSettings).enabled === true,
1384
- contextEnabled: savedSettings.contextEnabled !== false } }
2129
+ // No `live`, no `scope`, no `plan`, no `management`, no `unreadableSupply`, no
2130
+ // nullable row group: a failed source fails this read, it does not become a
2131
+ // shape the Client has to interpret.
2132
+ return { schema: 'ptc-capability-session-catalog/v2', sessionId, rows,
2133
+ config: { bindingsEnabled } }
1385
2134
  },
1386
2135
  async observeAllocation({ sessionId, configuration } = {}, signal) {
1387
2136
  signal?.throwIfAborted()
1388
- const agent = (ctx.agents?.list?.() ?? []).find(agent => String(agent.session.id ?? agent.id) === sessionId && canRefresh(agent))
1389
- if (!agent) throw new TypeError('ptc-plus: select an active PTC session to observe its capability inventory and workspace statistics')
2137
+ // The request names the session it observes; the live agent behind that id is
2138
+ // the parameter the observation needs. What that agent's tool table declares
2139
+ // is not a property of the target.
2140
+ const agent = (ctx.agents?.list?.() ?? []).find(agent => String(agent.session.id ?? agent.id) === sessionId)
2141
+ if (agent === undefined) throw new TypeError('ptc-plus: select a live session to observe its capability inventory and workspace statistics')
1390
2142
  const raw = configuration === undefined ? savedConfiguration(capabilityConfiguration(currentConfig)) : configuration
1391
2143
  const validated = capabilityProfileConfiguration(raw, { allowInvalidStatistics: true })
1392
2144
  const settings = capabilityConfiguration(validated)
@@ -1395,35 +2147,43 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
1395
2147
  const coverage = Number.isSafeInteger(settings.statistics?.windowSize) && settings.statistics.windowSize > 0
1396
2148
  ? await store?.coverage(agent, capabilities, settings.statistics) ?? null : null
1397
2149
  signal?.throwIfAborted()
1398
- if (!(ctx.agents?.list?.() ?? []).includes(agent) || !canRefresh(agent)) throw new Error('ptc-plus: capability observation target is no longer available')
2150
+ if (!(ctx.agents?.list?.() ?? []).includes(agent)) throw new Error('ptc-plus: capability observation target is no longer available')
1399
2151
  const plan = createCapabilityPlan({ ...settings, capabilities, coverage, navigationText: NAVIGATION,
1400
2152
  configurationRevision: digest(settings) })
1401
2153
  return { sessionId, workspace: agent.session.header.cwd, configuration: validated, plan, coverage,
1402
2154
  capabilities: capabilities.map(item => ({ id: item.id, revision: item.revision, category: item.category,
1403
2155
  contractBytes: Buffer.byteLength(item.contract, 'utf8') })) }
1404
2156
  },
1405
- async describeCatalog(signal) {
1406
- const available = new Map()
1407
- for (const agent of ctx.agents?.list?.() ?? []) {
1408
- signal?.throwIfAborted()
1409
- const state = stateFor(agent)
1410
- if (!state && !modelSchemasForAgent(agent).some(schema => schema.name === 'run_code')) continue
1411
- const hostSections = ctx.systemPrompt?.assemble
1412
- ? (await ctx.systemPrompt.assemble({ agent, scope: agent, signal, ptcPlusCapabilityObservation: true })).sections
1413
- : state?.hostSections ?? []
1414
- const presentation = new Map()
1415
- for (const capability of await inventory(agent, hostSections, capabilityConfiguration(currentConfig), signal, presentation)) {
1416
- const display = presentation.get(capability.id)
1417
- const previous = available.get(capability.id)
1418
- available.set(capability.id, { id: capability.id, category: capability.category, description: capability.description,
1419
- ...display, ...(capability.mcpServer === undefined ? {} : { mcpServer: capability.mcpServer }), ...(previous && JSON.stringify(previous.source) !== JSON.stringify(display?.source)
1420
- ? { source: { kind: 'unknown' }, mcpServer: undefined } : {}),
1421
- ...(previous && (previous.invocationConflict || JSON.stringify(previous.invocation) !== JSON.stringify(display?.invocation))
1422
- ? { invocation: undefined, invocationConflict: true } : {}) })
1423
- }
1424
- }
2157
+ /**
2158
+ * The settings page's list of what this Host declares.
2159
+ *
2160
+ * The settings page edits global policy, so its list states supply and nothing
2161
+ * else: no composed plan, no installed value. A caller that names a session gets
2162
+ * that session's own supply — its scope's declaration while the Host still holds
2163
+ * the agent, and the face its recorded plan froze when it does not, both read
2164
+ * through the same entry the panel reads. A caller that names none gets the
2165
+ * Host's own declaration without a workspace directory: that list is the saved
2166
+ * configuration's subject, not any session's face, so it is never substituted for
2167
+ * one. Neither branch asks whether a runtime exists.
2168
+ *
2169
+ * Skill invocation eligibility travels with the entry because it belongs to the
2170
+ * declaration the Host just made; this read sees one workspace, so it never has
2171
+ * two answers to reconcile.
2172
+ */
2173
+ async describeCatalog({ sessionId } = {}, signal) {
2174
+ signal?.throwIfAborted()
2175
+ const supply = typeof sessionId === 'string' && sessionId
2176
+ ? (await sessionCatalogSupply(sessionId, signal)).supply
2177
+ : await hostSupply({}, signal)
1425
2178
  signal?.throwIfAborted()
1426
- return [...available.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
2179
+ return supply.capabilities.map(capability => {
2180
+ const display = supply.presentation.get(capability.id)
2181
+ return {
2182
+ ...capabilityEntry(capability, supply.presentation),
2183
+ ...(display?.invocation === undefined ? {} : { invocation: display.invocation }),
2184
+ ...(capability.mcpServer === undefined ? {} : { mcpServer: capability.mcpServer }),
2185
+ }
2186
+ }).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
1427
2187
  },
1428
2188
  async disposeAgent(agent) {
1429
2189
  const projection = skillProjections.get(agent)