dsh-ptc-plus 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.en.md +4 -2
  2. package/README.md +4 -2
  3. package/client.cjs +3392 -2811
  4. package/compiler-core.cjs +127 -127
  5. package/compiler-core.cjs.map +2 -2
  6. package/docs/README.md +1 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  8. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  9. package/docs/adr/0023-global-user-bindings.md +12 -0
  10. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  11. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  13. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
  14. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  15. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  16. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  17. package/docs/adr/README.md +7 -1
  18. package/docs/architecture.md +1 -1
  19. package/docs/capability-planning-components.md +6 -6
  20. package/docs/capability-projection.md +1 -1
  21. package/docs/capability-surface.md +133 -0
  22. package/docs/client-ui.md +17 -15
  23. package/docs/client-visual-contract.md +7 -7
  24. package/docs/durability-design.md +9 -2
  25. package/docs/installation.md +17 -10
  26. package/docs/panel-concept.md +171 -0
  27. package/docs/releases/v0.5.2.md +26 -14
  28. package/docs/releases/v0.5.3.md +77 -0
  29. package/docs/releases/v0.5.4.md +75 -0
  30. package/docs/runtime-reference.md +21 -1
  31. package/docs/user-bindings.en.md +15 -0
  32. package/docs/user-bindings.md +15 -0
  33. package/docs/v0.5.3-goals.md +117 -0
  34. package/docs/verification.md +4 -4
  35. package/index.js +176 -41
  36. package/internal/binding-adoption.js +228 -0
  37. package/internal/capability-command-receipt.js +106 -8
  38. package/internal/capability-environment.js +1109 -312
  39. package/internal/capability-plan.js +18 -2
  40. package/internal/capability-policy.js +13 -0
  41. package/internal/capability-selection.js +330 -0
  42. package/internal/config-spec.js +14 -5
  43. package/internal/cordis-tools-owner.js +2 -1
  44. package/internal/direct-surface-owner.js +44 -17
  45. package/internal/edit-transport-owner.js +6 -5
  46. package/internal/execution-provider-owner.js +6 -0
  47. package/internal/execution-seam-compat.js +25 -0
  48. package/internal/host-tool-sdk.js +308 -0
  49. package/internal/message-sources.js +21 -0
  50. package/internal/official-repl-priority.js +31 -10
  51. package/internal/program-bindings.js +25 -5
  52. package/internal/repl-memory-projection.js +21 -2
  53. package/internal/runtime-bridge-owner.js +118 -9
  54. package/internal/runtime-config.js +10 -0
  55. package/internal/runtime-messages.js +5 -0
  56. package/internal/session-journal-recovery.js +210 -32
  57. package/internal/session-journal-schema.js +3 -0
  58. package/internal/session-journal.js +46 -9
  59. package/internal/session-log-view.js +69 -7
  60. package/internal/session-runtime.js +106 -39
  61. package/internal/settings-compat.js +6 -0
  62. package/internal/user-bindings-owner.js +126 -6
  63. package/package.json +59 -37
  64. package/src/client-authoring-view.js +7 -8
  65. package/src/client-capabilities-view.js +874 -909
  66. package/src/client-capability-sort.js +22 -9
  67. package/src/client-copy.js +118 -272
  68. package/src/client-indicator-view.js +2 -1
  69. package/src/client-repl-view.js +35 -16
  70. package/src/client-settings-view.js +90 -33
  71. package/src/client-styles.js +67 -19
  72. package/src/client.js +49 -22
package/index.js CHANGED
@@ -13,8 +13,9 @@ import { CapabilityStatisticsStore } from './internal/capability-statistics-stor
13
13
  import { createCapabilityEnvironment } from './internal/capability-environment.js'
14
14
  import { capabilityCommandReceipt } from './internal/capability-command-receipt.js'
15
15
  import { toolSdkSchemas } from './internal/program-bindings.js'
16
+ import { hostTransportDefinition, hostTransportUnreadable } from './internal/host-tool-sdk.js'
16
17
  import { createRuntimeMessageOwner, handBackRuntimeSessions } from './internal/runtime-contexts.js'
17
- import { createSessionLogOwner } from './internal/session-log-view.js'
18
+ import { createSessionLogOwner, projectionRegistrationDisposer } from './internal/session-log-view.js'
18
19
  import { PTC_DELIVERY_CONTEXT } from './internal/runtime-messages.js'
19
20
  import { createCordisToolsOwner } from './internal/cordis-tools-owner.js'
20
21
  import { createEditTransportOwner, EDIT_RUN_CODE } from './internal/edit-transport-owner.js'
@@ -32,6 +33,7 @@ import { valueLimitsFromConfig } from './internal/value-wire-schema.js'
32
33
  import { createReplMemoryProjection } from './internal/repl-memory-projection.js'
33
34
  import { createUserBindingDraftProjection } from './internal/user-binding-draft-projection.js'
34
35
  import { createUserBindingsOwner } from './internal/user-bindings-owner.js'
36
+ import { latestBindingAdoption } from './internal/binding-adoption.js'
35
37
  import { createHostRpc } from './internal/host-rpc.js'
36
38
  import { RPC_CONTRACTS } from './internal/rpc-contract.js'
37
39
  import { createReplObservationInterest } from './internal/repl-observation-interest.js'
@@ -112,8 +114,58 @@ function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durab
112
114
  return paragraphs.filter(paragraph => paragraph.trim().length > 0).join('\n\n')
113
115
  }
114
116
 
117
+ /**
118
+ * The reserved `run_code` transport definition, from whichever surface this
119
+ * host publishes it on. Session execution is available exactly when one of
120
+ * those surfaces answers.
121
+ */
122
+ function runCodeDefinitionFor(tools, scope) {
123
+ return hostTransportDefinition(tools, scope, RUN_CODE)
124
+ }
125
+
126
+ /**
127
+ * One catalogue row in the shape `/capabilities list` publishes.
128
+ *
129
+ * The command states the panel's two columns, so an entry carries the same
130
+ * statement the panel's column carries: exactly the dimensions that row decides,
131
+ * each naming the answer its own source gave. A dimension this session recorded
132
+ * no choice for stays stated as `inherit`. The saved column states the words the
133
+ * configuration wrote for that object: its own entry, or `automatic` when it wrote
134
+ * none — a category, server or bottom layer that would answer at runtime is
135
+ * behaviour, so it reaches no cell (capability-surface §8.1c). A server row states
136
+ * `automatic` unless its own `mcpServers.<server>` rule writes availability. Compressing a
137
+ * row into one word would
138
+ * answer for the dimension it dropped — a record that decided disclosure but not
139
+ * availability would read as if the session had decided nothing. No column
140
+ * reports the composition: the panel states supply, record and the receipt, and
141
+ * this listing is the same read rather than a second one that could answer for
142
+ * what a runtime built.
143
+ */
144
+ function capabilityListingEntry(row) {
145
+ // A server decides availability only: its members carry disclosure, so the panel
146
+ // states one value for such a row and this listing states the same one.
147
+ const statement = column => row.kind === 'server' ? { availability: column.availability }
148
+ : { availability: column.availability, disclosure: column.disclosure }
149
+ return { id: row.id, name: row.name, category: row.category, source: row.source,
150
+ ...(typeof row.description === 'string' ? { description: row.description } : {}),
151
+ saved: statement(row.saved), temporary: statement(row.session) }
152
+ }
153
+
154
+ /**
155
+ * The composition a pending capability command made, in the shape a receipt carries.
156
+ *
157
+ * A command that composed a plan before the Host accepted a step has to carry it in its
158
+ * receipt: the plan is not in the log yet, so a restart would otherwise have no way to know
159
+ * which composition the command made. `recordedPlan` reads it back by matching the command's
160
+ * own id and raw input, which is why the proposal states both.
161
+ */
162
+ function pendingProposal(capabilityEnvironment, commandId, rawInput, agent) {
163
+ const message = capabilityEnvironment.pendingChoiceMessage(agent)
164
+ return message === undefined ? undefined : { commandId, rawInput, message }
165
+ }
166
+
115
167
  /** Register the session-bound REPL runtime. */
116
- function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, settingsForCommand) {
168
+ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, settingsForCommand, readPromptText) {
117
169
  const presentationGeneration = randomUUID()
118
170
  const replMemoryProjection = createReplMemoryProjection(presentationGeneration)
119
171
  const userBindingDraftProjection = createUserBindingDraftProjection(presentationGeneration)
@@ -247,11 +299,17 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
247
299
  const cancelCordisActivation = () => pendingCordisActivation?.cancel()
248
300
  const registerProjection = (projection, label) => {
249
301
  try {
250
- const unregister = projectionService?.register?.(projection)
251
- if (typeof unregister !== 'function') {
252
- throw new Error('ptc-plus: sessionProjections.register did not return a disposer')
302
+ // A newer host returns a ProjectionHandle (`stateOf`/`readerFor`/`dispose`,
303
+ // possibly async); earlier hosts returned a disposer. Accept either shape so
304
+ // the projection is not silently dropped on the newer contract.
305
+ const dispose = projectionRegistrationDisposer(projectionService?.register?.(projection))
306
+ if (dispose === undefined) {
307
+ throw new Error('ptc-plus: sessionProjections.register returned neither a disposer nor a handle')
253
308
  }
254
- return unregister
309
+ // Unregistration failures belong to the caller that is suspending or
310
+ // reloading the projection: it retries and reports them. Swallowing one
311
+ // here would turn a failed disposal into a silent duplicate projection.
312
+ return () => dispose()
255
313
  } catch (error) {
256
314
  ctx.logger?.warn?.(`ptc-plus: ${label} projection unavailable`, error)
257
315
  return undefined
@@ -348,6 +406,9 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
348
406
  readSessionLog: sessionLog.read,
349
407
  enabled: activeConfig.userBindingsEnabled,
350
408
  draftProjectionAvailable,
409
+ // A session's adoption is its own recorded decision, so it is read from
410
+ // that session's log instead of a process-local map.
411
+ bindingAdoptionForAgent: agent => latestBindingAdoption(sessionLog.project(agent)?.ptcMessages ?? []),
351
412
  maxWallMs: activeConfig.maxWallMs,
352
413
  maxOutputBytes: activeConfig.maxOutputBytes,
353
414
  maxOldGenerationSizeMb: activeConfig.maxOldGenerationSizeMb,
@@ -355,11 +416,15 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
355
416
  valueLimits: valueLimitsFromConfig(activeConfig),
356
417
  })
357
418
  capabilityEnvironment = createCapabilityEnvironment({ ctx, config: activeConfig,
358
- toolSchemasForAgent: toolSdkSchemasForAgent, modelSchemasForAgent: toolSchemasForAgent,
419
+ toolSchemasForAgent: toolSdkSchemasForAgent,
420
+ // The panel's binding rows report the same catalog `/binding` acts on, so the
421
+ // read borrows the bindings owner's `list()` instead of opening a second store.
422
+ readUserBindings: () => userBindings.list(),
359
423
  store: new CapabilityStatisticsStore({ ctx }) })
360
424
  runtimeBridge = createRuntimeBridgeOwner({
361
425
  ctx,
362
426
  seam,
427
+ readPromptText,
363
428
  observeSession: id => observationInterest.has(id),
364
429
  readSessionLog: sessionLog.read,
365
430
  sessionConfig: activeConfig,
@@ -392,7 +457,8 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
392
457
  sessionId,
393
458
  toolSchemasForAgent,
394
459
  capabilityEnvironment,
395
- userBindingsForAgent: () => userBindings.snapshot(),
460
+ declaresRunCode: agent => runCodeDefinitionFor(ctx.tools, agent) !== undefined,
461
+ userBindingsForAgent: agent => userBindings.snapshotForAgent(agent),
396
462
  setAgentPresentation: (agent, presentation) => (
397
463
  userBindings.setAgentPresentation(agent, presentation)
398
464
  ),
@@ -401,25 +467,37 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
401
467
  name: 'tools:ptc-plus-repl',
402
468
  order: 98,
403
469
  text: context => {
404
- if (ctx.tools.get(RUN_CODE, context?.scope) === undefined) return ''
470
+ if (runCodeDefinitionFor(ctx.tools, context?.scope) === undefined) return ''
405
471
  return replGuidance(guidancePolicies(activeConfig), resolvePromptTextConfig(activeConfig))
406
472
  },
407
473
  }))
474
+ let unreadableTransportReported = false
408
475
  disposers.push(ctx.on('system-prompt/assemble', async (assembly, context, next) => {
409
476
  const projected = await directSurface.assemble(assembly, context, next)
410
- if (ctx.tools.get(RUN_CODE, context?.agent) !== undefined) runtimeBridge.prepareAgent(context?.agent)
477
+ // A capability observation states what the Host declares. It prepares no session,
478
+ // so it neither re-applies the model-facing slots nor reports a transport the
479
+ // operator can do nothing about from a read.
480
+ if (context?.ptcPlusCapabilityObservation === true) return projected
481
+ if (runCodeDefinitionFor(ctx.tools, context?.agent) !== undefined) runtimeBridge.prepareAgent(context?.agent)
482
+ else if (!unreadableTransportReported && hostTransportUnreadable(ctx.tools, context?.agent)) {
483
+ // The host declares the reserved transport and this plugin cannot read it.
484
+ // Every gate that needs execution answers "unavailable", so the operator is
485
+ // told which of the two facts holds instead of finding the features gone.
486
+ unreadableTransportReported = true
487
+ ctx.logger?.warn?.('ptc-plus: the host tool executor declares run_code but publishes no transport definition this plugin can read; session execution stays unavailable')
488
+ }
411
489
  return projected
412
490
  }))
413
491
  disposers.push(() => capabilityEnvironment.dispose())
414
492
  disposers.push(ctx.on('agent/pre-step', (payload, next) => capabilityEnvironment.preStep(payload, next), { prepend: true }))
415
493
  disposers.push(ctx.on('agent/created', ({ agent }) => {
416
- if (ctx.tools.get(RUN_CODE, agent) !== undefined) {
494
+ if (runCodeDefinitionFor(ctx.tools, agent) !== undefined) {
417
495
  runtimeBridge.prepareAgent(agent)
418
496
  editTransport.ensureInstalled(agent)
419
497
  }
420
498
  }))
421
499
  for (const agent of ctx.agents?.list?.() ?? []) {
422
- if (ctx.tools.get(RUN_CODE, agent) !== undefined) {
500
+ if (runCodeDefinitionFor(ctx.tools, agent) !== undefined) {
423
501
  runtimeBridge.prepareAgent(agent)
424
502
  editTransport.ensureInstalled(agent)
425
503
  }
@@ -432,7 +510,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
432
510
  const capabilitiesRpc = ctx.inject(['ptcPlusRpc'], scope => {
433
511
  scope.effect(() => scope.ptcPlusRpc.register(RPC_CONTRACTS.capabilities, async (operation, payload, signal) => {
434
512
  signal.throwIfAborted()
435
- if (operation === 'catalog') return { ok: true, value: await capabilityEnvironment.describeCatalog(signal) }
513
+ if (operation === 'catalog') return { ok: true, value: await capabilityEnvironment.describeCatalog(payload, signal) }
436
514
  if (operation === 'targets') return { ok: true, value: capabilityEnvironment.describeTargets() }
437
515
  if (operation === 'session-catalog') return { ok: true, value: await capabilityEnvironment.describeSessionCatalog(payload, signal) }
438
516
  if (operation === 'allocation') return { ok: true, value: await capabilityEnvironment.observeAllocation(payload, signal) }
@@ -448,36 +526,58 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
448
526
  input: { hint: 'refresh | list [all|skills|tools|mcp|mcp_tools] [query] | set session|global <id> [availability|disclosure] <choice>' },
449
527
  handler: async ({ commandId, agent, rawInput, signal }) => {
450
528
  const input = rawInput.trim()
451
- const receipt = (state, error) => capabilityCommandReceipt(state, String(agent.session.id), error,
452
- state === 'pending' && capabilityEnvironment.pendingChoiceMessage(agent)
453
- ? { commandId, rawInput, message: capabilityEnvironment.pendingChoiceMessage(agent) } : undefined)
529
+ // A composition the Host has not committed yet is not in the log, so a pending
530
+ // receipt carries it as the proposal it is: a restart reads that proposal back as
531
+ // the plan the command made. A record needs no proposal — it is in the log already.
532
+ const receipt = (state, error, record) => capabilityCommandReceipt(state, String(agent.session.id), error,
533
+ state === 'pending' ? pendingProposal(capabilityEnvironment, commandId, rawInput, agent) : undefined, record)
454
534
  const selection = /^set (session|global) (\S+) (?:(availability|disclosure) )?(full|on-demand|closed|automatic|inherit|enabled|host|adaptive)$/.exec(input)
455
535
  if (selection) {
456
- if (!capabilityEnvironment.canRefresh(agent)) return { kind: 'error', text: receipt('unavailable') }
536
+ // The write is stated before it happens: the receipt names the record
537
+ // this command asked for, and whether that record exists is the answer
538
+ // the command itself returns.
539
+ const record = { scope: selection[1], id: decodeURIComponent(selection[2]),
540
+ ...(selection[3] === undefined ? {} : { dimension: selection[3] }), selection: selection[4] }
457
541
  const changed = await capabilityEnvironment.change(agent, {
458
- scope: selection[1], id: decodeURIComponent(selection[2]), dimension: selection[3], selection: selection[4], commandId,
542
+ scope: record.scope, id: record.id, dimension: selection[3], selection: record.selection,
459
543
  }, signal, settingsForCommand?.())
544
+ // A receipt states what this command wrote — which entry, which choice
545
+ // and which session or global configuration now records it. What a cell
546
+ // later obtained is that row's own `evidence` field, not a sentence here.
547
+ // A session choice is a record of this session's log, so it is durable the
548
+ // moment the command returns and needs no waiting state.
549
+ const state = changed.sessionApplied ? 'recorded' : 'saved-only'
460
550
  return { kind: changed.sessionApplied ? 'success' : 'error',
461
- text: receipt(changed.sessionApplied ? changed.publication === 'pending' ? 'pending' : 'recorded' : 'saved-only', changed.error) }
551
+ text: receipt(state, changed.error, state === 'recorded' ? record : undefined) }
462
552
  }
463
553
  const listing = /^list(?: (all|skills|tools|mcp|mcp_tools))?(?: (.*))?$/.exec(input)
464
554
  if (listing) {
465
- const entries = await capabilityEnvironment.catalogFor(agent, signal)
555
+ // The listing is the panel's own read: one session catalogue, projected
556
+ // into the columns this command states. The session's scope supplies the
557
+ // declared face while the Host holds its agent, and the plan its log froze
558
+ // supplies it afterwards; the saved configuration states the saved column.
559
+ // Nothing about the PTC runtime enters it, so the two surfaces cannot
560
+ // disagree about which ids exist.
561
+ const catalog = await capabilityEnvironment.describeSessionCatalog({
562
+ sessionId: String(agent.session.id ?? agent.id),
563
+ }, signal)
466
564
  const query = (listing[2] ?? '').toLocaleLowerCase()
467
565
  const kind = listing[1] ?? 'all'
468
- return { kind: 'success', text: JSON.stringify({ schema: 'ptc-capability-catalog/v1', entries: entries.filter(entry => (
469
- kind === 'all' || kind === 'skills' && entry.category === 'skill'
470
- || kind === 'tools' && entry.category === 'tool' && entry.source?.kind !== 'mcp'
471
- || kind === 'mcp' && ['server', 'resource'].includes(entry.category)
472
- || kind === 'mcp_tools' && entry.category === 'tool' && entry.source?.kind === 'mcp'
473
- ) && (!query || `${entry.id} ${entry.description}`.toLocaleLowerCase().includes(query))) }) }
566
+ const entries = catalog.rows
567
+ .filter(row => row.kind !== 'binding')
568
+ .filter(row => kind === 'all' || kind === 'skills' && row.category === 'skill'
569
+ || kind === 'tools' && row.category === 'tool' && row.source?.kind !== 'mcp'
570
+ || kind === 'mcp' && ['server', 'resource'].includes(row.category)
571
+ || kind === 'mcp_tools' && row.category === 'tool' && row.source?.kind === 'mcp')
572
+ .filter(row => !query || `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLocaleLowerCase().includes(query))
573
+ .map(capabilityListingEntry)
574
+ return { kind: 'success', text: JSON.stringify({ schema: 'ptc-capability-catalog/v1', entries }) }
474
575
  }
475
576
  if (input !== '' && input !== 'refresh') return { kind: 'error', text: receipt('usage') }
476
577
  if (input === 'refresh') {
477
- if (!capabilityEnvironment.canRefresh(agent)) {
478
- return { kind: 'error', text: receipt('unavailable') }
479
- }
480
- await capabilityEnvironment.refresh(agent, undefined, signal, commandId)
578
+ // The command acts on the session it was invoked in; the agent is the
579
+ // argument that states which composition to replace.
580
+ await capabilityEnvironment.refresh(agent, undefined, signal)
481
581
  // A refresh always republishes the saved configuration; when that
482
582
  // configuration has capability management or context off, report
483
583
  // the off state instead of claiming an updated environment.
@@ -649,11 +749,16 @@ export const apply = (ctx, config = {}) => {
649
749
  function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatileConfig) {
650
750
  let settingsWriter
651
751
  const sessionLog = createSessionLogOwner(ctx)
652
- const toolSchemasForAgent = agent => typeof ctx.tools.schemas === 'function'
653
- ? ctx.tools.schemas(agent)
752
+ // The second argument is the step's admission. Naming it is how a caller states the set
753
+ // the Host actually delivers for that step; omitting it reads every definition
754
+ // registered at this scope, which is the only view a call outside a model step has.
755
+ const toolSchemasForAgent = (agent, toolSet = undefined) => typeof ctx.tools.schemas === 'function'
756
+ ? ctx.tools.schemas(agent, toolSet)
654
757
  : []
655
- // SDK contracts include output schemas; model-facing schemas remain unchanged.
656
- const toolSdkSchemasForAgent = agent => toolSdkSchemas(ctx.tools, agent)
758
+ // SDK contracts include output schemas; model-facing schemas remain unchanged. The
759
+ // admission is read through the same registry, so a contract describes the definition
760
+ // this step delivers rather than one the registry would resolve.
761
+ const toolSdkSchemasForAgent = (agent, toolSet = undefined) => toolSdkSchemas(ctx.tools, agent, toolSet)
657
762
  const sessionId = agent => {
658
763
  const id = agent?.session?.id ?? agent?.id
659
764
  return id === undefined ? undefined : String(id)
@@ -730,7 +835,9 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
730
835
  // Execution ownership is a per-attachment property: `owned` installs the
731
836
  // session runtime, `deferred` leaves run_code to the official provider.
732
837
  const executionShape = nextConfig => (
733
- defersToOfficialRepl(nextConfig, seam.provider) ? 'deferred' : 'owned'
838
+ defersToOfficialRepl(nextConfig, {
839
+ get retainsState() { return seam.originalRetainsState },
840
+ }) ? 'deferred' : 'owned'
734
841
  )
735
842
  const startActivation = record => {
736
843
  if (record.cancelled || disposed || activating !== record) {
@@ -744,7 +851,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
744
851
  try {
745
852
  candidate = record.shape === 'deferred'
746
853
  ? createDeferredRuntime()
747
- : installPtCRuntime(ctx, record.config, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, () => settingsWriter)
854
+ : installPtCRuntime(ctx, record.config, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, () => settingsWriter, currentPromptText)
748
855
  record.runtime = candidate
749
856
  } catch (error) {
750
857
  const failedRuntime = error?.[INSTALL_CLEANUP]
@@ -899,8 +1006,10 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
899
1006
  const handbackOwners = Object.fromEntries(['sessions', 'sessionPersistence', 'sessionQuery'].map(name => [name, ctx.get(name)]))
900
1007
  // One spelling of "the current document": the same resolved configuration the
901
1008
  // reconciliation loop applies, so a delivered message and the assembled prompt can
902
- // never read two different prompt-text documents. Read lazily: the settings section
903
- // replaces `configSource` after activation.
1009
+ // never read two different prompt-text documents. One holder per attachment, handed
1010
+ // to the runtime it installs, so two attachments in one process keep their own
1011
+ // document. Read lazily: the settings section replaces `configSource` after
1012
+ // activation.
904
1013
  const currentPromptText = () => resolvePromptTextConfig(configSource())
905
1014
  const messageOwner = createRuntimeMessageOwner(context => (
906
1015
  committed?.retiring ? [] : committed?.runtime.contextsForRequest(context) ?? []
@@ -918,8 +1027,17 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
918
1027
  ctx.effect(() => ctx.on('agent/disposed', ({ agent }) => messageOwner.disposeAgent(agent)), 'ptc-plus message agent disposal')
919
1028
  ctx.effect(() => () => messageOwner.dispose(), 'ptc-plus dynamic message lifecycle')
920
1029
  ctx.effect(() => async () => {
921
- await controller.dispose()
1030
+ // The model-visible handback states a fact about execution, so it must not
1031
+ // ride on whether every projection unregistered: a failed disposal is
1032
+ // reported by the caller that retries it, and the runtime is gone either way.
1033
+ let disposalFailure
1034
+ try {
1035
+ await controller.dispose()
1036
+ } catch (error) {
1037
+ disposalFailure = error
1038
+ }
922
1039
  await handBackRuntimeSessions(ctx, handbackOwners)
1040
+ if (disposalFailure !== undefined) throw disposalFailure
923
1041
  }, 'ptc-plus provider handback')
924
1042
 
925
1043
  // The settings section replaces this source after activation; the prompt-text
@@ -937,11 +1055,28 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
937
1055
  try {
938
1056
  if (settingsWriter?.update === undefined) return
939
1057
  const previousConfig = controller.committedConfig()
1058
+ // A policy this process synthesized is not a record the settings document holds: write it
1059
+ // back and the next resolution reports it as saved, so the rollback removes the field with
1060
+ // the service's own `unset` op when it offers one. The two switches derived beside it are
1061
+ // restated to the committed values; a service without `unset` keeps the previous, narrower
1062
+ // behaviour and the residual is stated in capability-surface §8.7.
1063
+ const keepDerived = previousConfig?.capabilityPolicySaved === true
1064
+ // Only a committed configuration that reports it saved no policy lets the rollback remove
1065
+ // one: with no committed record this process does not know what the document holds, and
1066
+ // deleting a policy it never wrote would erase rules the user saved.
1067
+ const removable = previousConfig !== undefined && previousConfig.capabilityPolicySaved === false
1068
+ && typeof settingsWriter.mutate === 'function'
940
1069
  const patch = previousConfig === undefined
941
1070
  ? { enabled: false }
942
- : Object.fromEntries(CONFIG_FIELDS.map(field => [field.key, previousConfig[field.key]]))
1071
+ : Object.fromEntries(CONFIG_FIELDS
1072
+ // Only the policy is synthesized with the two switches; a writer without `mutate` cannot
1073
+ // remove it, so it omits that one field and still restates everything else it derived.
1074
+ .filter(field => keepDerived || field.key !== 'capabilityPolicy')
1075
+ .map(field => [field.key, previousConfig[field.key]]))
943
1076
  try {
944
- await settingsWriter.update(patch)
1077
+ if (removable && Object.keys(patch).length > 0) await settingsWriter.update(patch)
1078
+ if (removable) await settingsWriter.mutate([{ op: 'unset', path: ['capabilityPolicy'] }])
1079
+ else await settingsWriter.update(patch)
945
1080
  } catch (rollbackError) {
946
1081
  reportActivationFailure(new Error(
947
1082
  `ptc-plus: failed to roll back runtime configuration: ${rollbackError.message}`,
@@ -0,0 +1,228 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
2
+ import { isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND, RETIRED_SURFACE_RECORD } from './message-sources.js'
3
+
4
+ /**
5
+ * Session binding adoption: which Global User Binding catalog entries this
6
+ * session uses.
7
+ *
8
+ * The question is answered per session and per entry, and the answer is a
9
+ * decision of exactly two states: this session uses the entry, or it does not.
10
+ * An entry the session never named is used, because the global catalog already
11
+ * answered the supply question. That default is the absence of a decision, not
12
+ * a third state: "use" and "never mentioned" lead to the same environment, so
13
+ * they are one state rather than two.
14
+ *
15
+ * Adoption can only take an entry away from this session. It cannot supply one,
16
+ * enable one, or name a source: an entry the global configuration disabled is
17
+ * absent from the catalog, so "adoption revives a deleted entry" has no
18
+ * encoding here and needs no rule forbidding it. Naming an entry the catalog
19
+ * does not offer is rejected at the surface that states the decision, because
20
+ * the record would otherwise claim authority over supply.
21
+ *
22
+ * The neighbour this must not be confused with is the capability pane's
23
+ * `automatic`, which means "the host derives this value". A binding has no host
24
+ * derivation to defer to, so borrowing that word would misdescribe both.
25
+ *
26
+ * The decision is recorded as a plugin record in the session log, so it is
27
+ * reconstructed by replaying that log after a restart instead of being kept in
28
+ * process memory. The message form is the historical carrier that 0.5.4 and
29
+ * earlier wrote; it is still read (those sessions keep their decision, and a
30
+ * repaired statement answers through it), and no writer produces it.
31
+ */
32
+ export const BINDING_ADOPTION_FORM = 'binding-adoption'
33
+ export const BINDING_ADOPTION_SCHEMA = 'ptc-binding-adoption/v1'
34
+
35
+ /**
36
+ * The log record this session's adoption is written as.
37
+ *
38
+ * Adoption is a decision about this session's own environment: the catalog the
39
+ * model reads already reflects it, so the decision needs no message of its own.
40
+ * Writing it as a record makes it durable and immediate — it is taken the moment
41
+ * the control is used, before any step, without entering the model-visible surface
42
+ * (a surface node ahead of the host's protected head would make the log
43
+ * unreadable to every reader). A host that publishes no plugin-record writer
44
+ * refuses the write instead of falling back to that surface node.
45
+ */
46
+ export const BINDING_ADOPTION_RECORD = 'plugin:ptc-plus/binding-adoption'
47
+
48
+ const MAX_DECLINED = 64
49
+ const ENTRY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
50
+ const PREFIX = 'PTC Plus session binding adoption. This session uses every entry the global catalog supplies except the declined ones. Adoption can only withdraw an entry from this session: it cannot supply, enable or revive one, and the global configuration decides which entries exist at all. Declined entry IDs:'
51
+
52
+ function isRecord(value) {
53
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
54
+ }
55
+
56
+ function sortIds(ids) {
57
+ return [...ids].sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
58
+ }
59
+
60
+ export function normalizeBindingAdoption(value) {
61
+ if (!isRecord(value) || Reflect.ownKeys(value).length !== 2
62
+ || !Object.hasOwn(value, 'schema') || !Object.hasOwn(value, 'declined')) {
63
+ throw new TypeError('invalid binding adoption')
64
+ }
65
+ if (value.schema !== BINDING_ADOPTION_SCHEMA) throw new TypeError('invalid binding adoption schema')
66
+ if (!Array.isArray(value.declined) || value.declined.length > MAX_DECLINED) {
67
+ throw new TypeError('invalid binding adoption declined entries')
68
+ }
69
+ const declined = []
70
+ for (const id of value.declined) {
71
+ if (typeof id !== 'string' || !ENTRY_ID.test(id)) throw new TypeError('invalid binding adoption entry id')
72
+ if (declined.includes(id)) throw new TypeError('duplicate binding adoption entry id')
73
+ declined.push(id)
74
+ }
75
+ return Object.freeze({ schema: BINDING_ADOPTION_SCHEMA, declined: Object.freeze(sortIds(declined)) })
76
+ }
77
+
78
+ /** The exact model-visible wording one adoption record carries. */
79
+ export function bindingAdoptionText(adoption) {
80
+ return `${PREFIX}\n${JSON.stringify(normalizeBindingAdoption(adoption).declined)}`
81
+ }
82
+
83
+ /** The historical message this adoption was written as before the record carrier. */
84
+ export function bindingAdoptionMessage(adoption) {
85
+ const text = bindingAdoptionText(adoption)
86
+ const message = createUserMessage({
87
+ source: { kind: PTC_MESSAGE_SOURCE_KIND, form: BINDING_ADOPTION_FORM },
88
+ content: [{ type: 'text', text }],
89
+ })
90
+ if (readBindingAdoption(message) === undefined) throw new TypeError('invalid binding adoption message')
91
+ return message
92
+ }
93
+
94
+ /** Read one adoption record back from a message, message projection or message fact. */
95
+ export function readBindingAdoption(value) {
96
+ const source = value?.source
97
+ if (!isPtcMessageSource(source) || source.form !== BINDING_ADOPTION_FORM) return undefined
98
+ if (!Array.isArray(value.content) || value.content.length !== 1 || value.content[0]?.type !== 'text') return undefined
99
+ const text = value.content[0].text
100
+ if (typeof text !== 'string' || !text.startsWith(`${PREFIX}\n`)) return undefined
101
+ try {
102
+ const declined = JSON.parse(text.slice(PREFIX.length + 1))
103
+ const adoption = normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined })
104
+ return bindingAdoptionText(adoption) === text ? adoption : undefined
105
+ } catch {
106
+ return undefined
107
+ }
108
+ }
109
+
110
+ /**
111
+ * How this session's newest adoption statement reads.
112
+ *
113
+ * `none` is the absence of a decision, `decided` is a statement this build can
114
+ * read, and `unreadable` is a statement it cannot. The third state is not the
115
+ * first: an unreadable newest statement still supersedes every earlier record,
116
+ * so no consumer may answer from the older one in its place. `latestBindingAdoption`
117
+ * collapses the last two into `undefined`, which is why the panel asks this
118
+ * function instead: `undefined` there means "no decision to apply", not "the
119
+ * session declined nothing".
120
+ */
121
+ export function classifyBindingAdoption(records) {
122
+ if (!Array.isArray(records)) return Object.freeze({ state: 'none' })
123
+ for (let index = records.length - 1; index >= 0; index -= 1) {
124
+ const record = records[index]
125
+ if (record?.form !== BINDING_ADOPTION_FORM) continue
126
+ try {
127
+ return Object.freeze({ state: 'decided',
128
+ adoption: normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: record.declined }) })
129
+ } catch {
130
+ // A record that cannot be read is not evidence for an earlier record
131
+ // either: the newest statement is the current one, and it is unreadable.
132
+ return Object.freeze({ state: 'unreadable' })
133
+ }
134
+ }
135
+ return Object.freeze({ state: 'none' })
136
+ }
137
+
138
+ /** Read one committed plugin record back as this session's adoption, or nothing. */
139
+ export function readBindingAdoptionRecord(event) {
140
+ if (event?.type !== BINDING_ADOPTION_RECORD || event.ignorable !== true) return undefined
141
+ try {
142
+ return normalizeBindingAdoption(event.data)
143
+ } catch {
144
+ return undefined
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Whether one event states this session's adoption at all.
150
+ *
151
+ * The record type states it, and so does the historical message form. A repaired statement
152
+ * (`RETIRED_SURFACE_RECORD`) states it only when the payload it kept is this owner's own form:
153
+ * the repair retires every surface node ahead of the protected head, whichever owner wrote it,
154
+ * so the carrier alone says nothing about who the statement belongs to.
155
+ */
156
+ function statesBindingAdoption(event) {
157
+ if (event?.type === BINDING_ADOPTION_RECORD) return true
158
+ const source = event?.data?.source
159
+ return isPtcMessageSource(source) && source.form === BINDING_ADOPTION_FORM
160
+ }
161
+
162
+ /**
163
+ * Read one adoption out of either carrier, so a reader never has to know which release wrote it —
164
+ * and a repaired statement answers for the owner whose form it carries.
165
+ */
166
+ export function readBindingAdoptionStatement(event) {
167
+ if (event?.type === BINDING_ADOPTION_RECORD) return readBindingAdoptionRecord(event)
168
+ if (event?.type === RETIRED_SURFACE_RECORD) {
169
+ return event.ignorable === true && statesBindingAdoption(event) ? readBindingAdoption(event.data) : undefined
170
+ }
171
+ return event?.type === 'user/message' ? readBindingAdoption(event.data) : undefined
172
+ }
173
+
174
+ /**
175
+ * This session's newest adoption statement, read from its own log events.
176
+ *
177
+ * The log is the owner of the decision, so every adoption statement it carries — a record, the
178
+ * message form an older release wrote, or a repaired statement of the same form — is classified,
179
+ * including one this build cannot read. Extracting the statements and classifying them here keeps
180
+ * every reader on the same cut: a reader that filtered to the readable statements first would hand
181
+ * a superseded record the answer, and the panel and the `/binding` command would then disagree
182
+ * about which record governs this session.
183
+ */
184
+ export function bindingAdoptionOf(events) {
185
+ const records = []
186
+ for (const event of Array.isArray(events) ? events : []) {
187
+ if (event?.type !== 'user/message' && event?.type !== BINDING_ADOPTION_RECORD
188
+ && event?.type !== RETIRED_SURFACE_RECORD) continue
189
+ // A repair retires every owner's surface node, so only a statement that kept this owner's
190
+ // form joins the ordering; another owner's retired node is not an adoption at all.
191
+ if (!statesBindingAdoption(event)) continue
192
+ records.push({ form: BINDING_ADOPTION_FORM, declined: readBindingAdoptionStatement(event)?.declined })
193
+ }
194
+ return classifyBindingAdoption(records)
195
+ }
196
+
197
+ /**
198
+ * The newest adoption this session recorded, or `undefined` while it recorded
199
+ * none this build can read. The newest record replaces earlier ones, because
200
+ * stating a decision again is how a session changes its mind.
201
+ */
202
+ export function latestBindingAdoption(records) {
203
+ const statement = classifyBindingAdoption(records)
204
+ return statement.state === 'decided' ? statement.adoption : undefined
205
+ }
206
+
207
+ /**
208
+ * The declined entry IDs of one adoption, as a set the environment uses.
209
+ * An adoption that was never stated declines nothing.
210
+ */
211
+ export function declinedBindingEntries(adoption) {
212
+ return new Set(adoption === undefined ? [] : normalizeBindingAdoption(adoption).declined)
213
+ }
214
+
215
+ /** State one entry as used (`adopt` true) or not used, leaving the rest alone. */
216
+ export function withBindingAdoption(adoption, entryId, adopt) {
217
+ if (typeof entryId !== 'string' || !ENTRY_ID.test(entryId)) throw new TypeError('invalid binding adoption entry id')
218
+ if (typeof adopt !== 'boolean') throw new TypeError('binding adoption decision must be a boolean')
219
+ const declined = declinedBindingEntries(adoption)
220
+ if (adopt) declined.delete(entryId)
221
+ else declined.add(entryId)
222
+ return normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: [...declined] })
223
+ }
224
+
225
+ /** Drop every declined entry: this session again uses the whole catalog. */
226
+ export function clearedBindingAdoption() {
227
+ return normalizeBindingAdoption({ schema: BINDING_ADOPTION_SCHEMA, declined: [] })
228
+ }