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
@@ -83,11 +83,19 @@ function createLegacyCapabilityPlan(input) {
83
83
  if (!Array.isArray(options.capabilities)) throw new TypeError('ptc-plus: capabilities must be an array')
84
84
  const ids = new Set()
85
85
  const excluded = []
86
+ // Ids whose own invocation path the inventory could not reach. The planner states
87
+ // availability from the recorded choices, so a row the inventory already proved
88
+ // unreachable is closed here too — otherwise a baseline full contract keeps instructing
89
+ // a call the composition does not deliver. The policy generation states the same fact as
90
+ // `availabilitySource: 'invocation-path'`; a recorded v1/v2 inventory carries no such
91
+ // field, so its recomposition is unchanged.
92
+ const pathClosed = new Set()
86
93
  const capabilities = options.capabilities.map(raw => {
87
94
  const value = record(raw, 'capability')
88
95
  const id = text(value.id, 'capability id')
89
96
  if (ids.has(id)) throw new TypeError(`ptc-plus: duplicate capability identity ${id}`)
90
97
  ids.add(id)
98
+ if (value.effectiveAvailability === 'closed') pathClosed.add(id)
91
99
  const revision = text(value.revision, 'capability revision')
92
100
  if (EXCLUDED_CATEGORIES.has(value.category)) {
93
101
  excluded.push({ id, revision, category: value.category })
@@ -148,8 +156,10 @@ function createLegacyCapabilityPlan(input) {
148
156
  throw new TypeError(`ptc-plus: cannot determine the MCP server owner of ${capability.id}; conflicting namespaces: ${capability.mcpServerCandidates.join(', ')}`)
149
157
  }
150
158
  const serverClosed = capability.mcpServer !== undefined && overrides[`mcpServers.${capability.mcpServer}`] === 'closed'
151
- const explicit = serverClosed ? 'closed' : Object.hasOwn(overrides, capability.id) ? overrides[capability.id] : undefined
152
- const entry = { id: capability.id, revision: capability.revision, category: capability.category, selection: explicit ?? 'on-demand', reason: serverClosed ? 'mcp-server-disabled' : explicit ? 'manual-override' : 'baseline-not-selected' }
159
+ const unreachable = pathClosed.has(capability.id)
160
+ const explicit = serverClosed || unreachable ? 'closed' : Object.hasOwn(overrides, capability.id) ? overrides[capability.id] : undefined
161
+ const entry = { id: capability.id, revision: capability.revision, category: capability.category, selection: explicit ?? 'on-demand',
162
+ reason: serverClosed ? 'mcp-server-disabled' : unreachable ? 'invocation-path' : explicit ? 'manual-override' : 'baseline-not-selected' }
153
163
  entries.push(entry)
154
164
  selections.set(capability.id, entry)
155
165
  if (explicit === 'full') full.push(capability)
@@ -235,6 +245,12 @@ function createLegacyCapabilityPlan(input) {
235
245
  schema, enabled: true, configurationRevision, entries,
236
246
  excluded: excluded.sort(compareIds), catalogText, directoryText, directoryCategories,
237
247
  budgets: { unit: 'utf8-bytes', budgetBytes, directoryBudgetBytes, contractBytes: bytes(catalogText), directoryBytes: bytes(directoryText), overageBytes: Math.max(0, bytes(catalogText) - budgetBytes) },
248
+ // Configured ids this composition resolved no definition for. The composition is built
249
+ // from the step's admission, so a configured capability that step does not deliver is
250
+ // listed here beside one that no longer exists at all — the field states what this
251
+ // plan could not resolve, not what this session may call, and no model-visible text is
252
+ // rendered from it. Reading it as the capability list would name a closed capability
253
+ // as available and an absent one as configured.
238
254
  unresolvedConfigurationIds: [...new Set([...baseline, ...Object.keys(overrides)])].filter(id => !capabilities.some(entry => entry.id === id)).sort(),
239
255
  statisticsStatus: settings.usable ? 'enabled' : settings.reason,
240
256
  })
@@ -82,6 +82,19 @@ export function capabilityRule(policy, capability) {
82
82
  return { availability, disclosure, availabilitySource, disclosureSource }
83
83
  }
84
84
 
85
+ /** The policy document a record's own words make.
86
+ *
87
+ * A configuration that saved no policy has only a synthesized document, whose bottom layer is
88
+ * shaped by switches that belong to no record. Editing a rule, or writing one saved cell, must
89
+ * start from the record's own layers instead: the declared defaults plus the layers the record
90
+ * wrote (the legacy override record and the usage-statistics switch `capabilityPolicyForConfig`
91
+ * folds into the allocation).
92
+ */
93
+ export function capabilityPolicyFromRecord(config) {
94
+ return capabilityPolicyForConfig({ ...config, capabilityPolicy: undefined,
95
+ capabilityManagementEnabled: false })
96
+ }
97
+
85
98
  /** Change one reusable dimension, removing only that dimension for automatic. */
86
99
  export function changeCapabilityRule(policy, id, dimension, choice) {
87
100
  const next = structuredClone(policy)
@@ -0,0 +1,330 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
2
+ import { CAPABILITY_PLAN_FORM, isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND, RETIRED_SURFACE_RECORD } from './message-sources.js'
3
+ import { CAPABILITY_DISCLOSURES } from './capability-policy.js'
4
+
5
+ /**
6
+ * Session capability choices: the capability decisions this session recorded.
7
+ *
8
+ * The panel states two different facts side by side. The saved column answers
9
+ * "what does the global configuration say", and this record answers "what did
10
+ * this session decide". Keeping them apart is what lets a session state a choice
11
+ * while the global configuration still reads as it did: neither answer is
12
+ * derived from the other, and neither needs a session runtime.
13
+ *
14
+ * Three states, one question: no record (`none`) means the session inherits the
15
+ * saved column; a readable newest record (`decided`) is that session's own
16
+ * answer; a newest record this build cannot parse (`unreadable`) supersedes the
17
+ * readable ones before it, so no consumer may answer from an older record in its
18
+ * place. The third state is a fact about that record, not a state of the panel:
19
+ * every row it touches keeps its control, and recording a new decision replaces
20
+ * it.
21
+ *
22
+ * A choice never claims supply. It names an entry by id and never adds or
23
+ * restores a row, because the row it applies to exists only while the host
24
+ * declares that entry. A record may therefore outlive the declaration it was
25
+ * written for: the session keeps its decision, the catalogue stops offering the
26
+ * row, and nothing reads the record as a supply. `change()` asks this module's
27
+ * vocabulary before it appends, so a decision about an entry this vocabulary
28
+ * cannot name never becomes a record.
29
+ *
30
+ * A record is not the only shape this session's decision has ever had. Before
31
+ * this record existed, the plugin wrote a session's choices into a plan snapshot —
32
+ * the one it delivered, or the one a pending command receipt carried when no step
33
+ * had accepted it yet — and those sessions have no record at all. Reading such
34
+ * a session as `none` would recompose it from an empty set and withdraw choices
35
+ * its own log still holds, so this module reads the newest delivered plan's
36
+ * `sessionOverrides` as the same statement (`decided`, marked `legacy`) when the
37
+ * session recorded none. The delivered plan is the equivalent form of one
38
+ * statement, never a second owner: a record outvotes it, and the next write
39
+ * restates every choice this read answered with. Only a plan that delivered a
40
+ * choice is such a statement — an empty override map says the session inherited
41
+ * everything, which is the state a session without a record already reads as — and
42
+ * a plan that states the map unreadably reports `unreadable` for the same reason a
43
+ * record does.
44
+ *
45
+ * A choice about this session's capability set is a statement the session makes
46
+ * about itself, not correspondence with the model, and the carrier it travels on
47
+ * decides when it may be written. It travels as the plugin record below, which the
48
+ * host retains on every read and interprets as a surface node by none: a decision
49
+ * taken before the session's first step is therefore durable at once and never
50
+ * becomes a surface node ahead of the protected head. The `user/message` form is
51
+ * the historical carrier that 0.5.4 and earlier wrote; it is still read (a session
52
+ * persisted by those releases keeps its decision), and no writer produces it.
53
+ */
54
+ export const CAPABILITY_SELECTION_FORM = 'capability-selection'
55
+ export const CAPABILITY_SELECTION_SCHEMA = 'ptc-capability-selection/v1'
56
+
57
+ /**
58
+ * The log record this session's own choice is written as.
59
+ *
60
+ * A choice about this session's capability set is the session's statement about
61
+ * itself, not correspondence with the model, so it belongs in the log beside the
62
+ * turn events rather than on the model-visible surface. A plugin record is the
63
+ * host's carrier for exactly that kind of fact: it is retained on every read,
64
+ * interpreted as a surface node by none, and it may be written at any moment —
65
+ * including before the host publishes its protected surface head, where a message
66
+ * would make the whole log unreadable.
67
+ *
68
+ * A record that exists only as a plugin record means the step that composes the
69
+ * next environment is the only reader that has to know it, which is why the panel
70
+ * and the planner read this type directly instead of waiting for a delivery.
71
+ */
72
+ export const CAPABILITY_SELECTION_RECORD = 'plugin:ptc-plus/capability-selection'
73
+
74
+ const MAX_CHOICES = 256
75
+ /** The one entry-id vocabulary a choice record may use; its writer validates the same form. */
76
+ export const CAPABILITY_ENTRY_ID = /^(tools|skills|mcp|mcpServers)\.[^\s]{1,200}$/
77
+ const ENTRY_ID = CAPABILITY_ENTRY_ID
78
+ const AVAILABILITY = ['enabled', 'closed', 'automatic']
79
+ const DISCLOSURE = ['automatic', ...CAPABILITY_DISCLOSURES]
80
+ const PREFIX = 'PTC Plus session capability choices. These are this session\'s own decisions; the global configuration still owns the saved column, and the host still owns which entries exist. Choices:'
81
+
82
+ function isRecord(value) {
83
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
84
+ }
85
+
86
+ function sortedEntries(choices) {
87
+ return Object.entries(choices).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
88
+ }
89
+
90
+ export function normalizeCapabilitySelection(value) {
91
+ if (!isRecord(value) || Reflect.ownKeys(value).length !== 2
92
+ || !Object.hasOwn(value, 'schema') || !Object.hasOwn(value, 'choices')) {
93
+ throw new TypeError('invalid capability selection')
94
+ }
95
+ if (value.schema !== CAPABILITY_SELECTION_SCHEMA) throw new TypeError('invalid capability selection schema')
96
+ if (!isRecord(value.choices) || Reflect.ownKeys(value.choices).length > MAX_CHOICES) {
97
+ throw new TypeError('invalid capability selection choices')
98
+ }
99
+ const choices = {}
100
+ for (const [id, choice] of sortedEntries(value.choices)) {
101
+ if (!ENTRY_ID.test(id) || !isRecord(choice) || Reflect.ownKeys(choice).length === 0) {
102
+ throw new TypeError('invalid capability selection entry')
103
+ }
104
+ const next = {}
105
+ for (const [dimension, selection] of Object.entries(choice)) {
106
+ const allowed = dimension === 'availability' ? AVAILABILITY
107
+ : dimension === 'disclosure' ? DISCLOSURE : undefined
108
+ if (allowed === undefined || !allowed.includes(selection)) throw new TypeError('invalid capability selection dimension')
109
+ next[dimension] = selection
110
+ }
111
+ choices[id] = next
112
+ }
113
+ return Object.freeze({ schema: CAPABILITY_SELECTION_SCHEMA,
114
+ choices: Object.freeze(Object.fromEntries(Object.entries(choices).map(([id, choice]) => [id, Object.freeze(choice)]))) })
115
+ }
116
+
117
+ /** The exact model-visible wording one selection record carries. */
118
+ export function capabilitySelectionText(selection) {
119
+ return `${PREFIX}\n${JSON.stringify(normalizeCapabilitySelection(selection).choices)}`
120
+ }
121
+
122
+ /**
123
+ * The historical message this statement was written as before the record carrier.
124
+ *
125
+ * 0.5.4 and earlier appended this message on the model-visible surface, which is what
126
+ * `scripts/repair-session-log.mjs` repairs. No writer produces it now: the record below is
127
+ * the statement, and this form survives so that a session persisted by one of those releases
128
+ * is still read, and so that the repair has the shape it retires to build a fixture from.
129
+ */
130
+ export function capabilitySelectionMessage(selection) {
131
+ const text = capabilitySelectionText(selection)
132
+ const message = createUserMessage({
133
+ source: { kind: PTC_MESSAGE_SOURCE_KIND, form: CAPABILITY_SELECTION_FORM },
134
+ content: [{ type: 'text', text }],
135
+ })
136
+ if (readCapabilitySelection(message) === undefined) throw new TypeError('invalid capability selection message')
137
+ return message
138
+ }
139
+
140
+ /** Read one committed plugin record back as this session's selection, or nothing. */
141
+ export function readCapabilitySelectionRecord(event) {
142
+ if (event?.type !== CAPABILITY_SELECTION_RECORD || event.ignorable !== true) return undefined
143
+ try {
144
+ return normalizeCapabilitySelection(event.data)
145
+ } catch {
146
+ return undefined
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Whether one event states this session's capability selection at all.
152
+ *
153
+ * The record type states it, and so does the historical message form. A repaired statement
154
+ * (`RETIRED_SURFACE_RECORD`) states it only when the payload it kept is this owner's own form:
155
+ * a repair retires every surface node ahead of the protected head, whichever owner wrote it, so
156
+ * the carrier alone says nothing about who the statement belongs to. Reading another owner's
157
+ * retired statement as this one would answer `unreadable` for a decision this session never made
158
+ * — and would then outvote the selection the session did record.
159
+ */
160
+ function statesCapabilitySelection(event) {
161
+ if (event?.type === CAPABILITY_SELECTION_RECORD) return true
162
+ const source = event?.data?.source
163
+ return isPtcMessageSource(source) && source.form === CAPABILITY_SELECTION_FORM
164
+ }
165
+
166
+ /**
167
+ * Read one selection out of either carrier.
168
+ *
169
+ * A record and the historical message state the same selection, so a caller that walks a log
170
+ * does not have to know which release wrote it — and a repaired statement answers for the owner
171
+ * whose form it carries.
172
+ */
173
+ export function readCapabilitySelectionStatement(event) {
174
+ if (event?.type === CAPABILITY_SELECTION_RECORD) return readCapabilitySelectionRecord(event)
175
+ if (event?.type === RETIRED_SURFACE_RECORD) {
176
+ return event.ignorable === true && statesCapabilitySelection(event) ? readCapabilitySelection(event.data) : undefined
177
+ }
178
+ return event?.type === 'user/message' ? readCapabilitySelection(event.data) : undefined
179
+ }
180
+
181
+ /** Read one selection record back from a message, message projection or message fact. */
182
+ export function readCapabilitySelection(value) {
183
+ const source = value?.source
184
+ if (!isPtcMessageSource(source) || source.form !== CAPABILITY_SELECTION_FORM) return undefined
185
+ if (!Array.isArray(value.content) || value.content.length !== 1 || value.content[0]?.type !== 'text') return undefined
186
+ const text = value.content[0].text
187
+ if (typeof text !== 'string' || !text.startsWith(`${PREFIX}\n`)) return undefined
188
+ try {
189
+ const choices = JSON.parse(text.slice(PREFIX.length + 1))
190
+ const selection = normalizeCapabilitySelection({ schema: CAPABILITY_SELECTION_SCHEMA, choices })
191
+ return capabilitySelectionText(selection) === text ? selection : undefined
192
+ } catch {
193
+ return undefined
194
+ }
195
+ }
196
+
197
+ /**
198
+ * How this session's newest selection record reads.
199
+ *
200
+ * The newest record is the current statement whether or not this build can parse
201
+ * it, so an unreadable one is not evidence for an earlier record either.
202
+ */
203
+ export function classifyCapabilitySelection(records) {
204
+ if (!Array.isArray(records)) return Object.freeze({ state: 'none' })
205
+ for (let index = records.length - 1; index >= 0; index -= 1) {
206
+ const record = records[index]
207
+ if (record?.form !== CAPABILITY_SELECTION_FORM) continue
208
+ try {
209
+ return Object.freeze({ state: 'decided',
210
+ selection: normalizeCapabilitySelection({ schema: CAPABILITY_SELECTION_SCHEMA, choices: record.choices }) })
211
+ } catch {
212
+ return Object.freeze({ state: 'unreadable' })
213
+ }
214
+ }
215
+ return Object.freeze({ state: 'none' })
216
+ }
217
+
218
+ /**
219
+ * The dimension rule one selection states, or `{}` when it states no decision.
220
+ *
221
+ * One selection is the vocabulary a saved decision, a command intent and a
222
+ * delivered plan's override all use; a record states dimensions. This function
223
+ * is the only place the two spellings meet, so a writer that copies a selection
224
+ * into a record and a reader that turns a delivered override back into one
225
+ * cannot disagree about what that selection meant. Anything narrower — a rule
226
+ * naming only one dimension — is already in this shape and is copied as it is.
227
+ */
228
+ export function capabilityRuleOfSelection(selection) {
229
+ if (isRecord(selection)) return { ...selection }
230
+ return selection === undefined || selection === 'inherit' ? {}
231
+ : selection === 'automatic' ? { availability: 'automatic', disclosure: 'automatic' }
232
+ : selection === 'closed' ? { availability: 'closed' } : { availability: 'enabled', disclosure: selection }
233
+ }
234
+
235
+ /**
236
+ * The dimension form of one recorded-by-plan override.
237
+ *
238
+ * A delivered plan recorded this session's choices in the compact vocabulary a
239
+ * saved configuration uses — one whole selection per entry, or the dimensions a
240
+ * policy rule states — while a record states dimensions only. The two meet in
241
+ * `capabilityRuleOfSelection`, which is why the plan is an equivalent form of one
242
+ * statement rather than a second vocabulary: every value it reads maps back to
243
+ * the same compact selection, so a choice read from a plan and restated into a
244
+ * record says what the plan said.
245
+ */
246
+ function planChoice(override) {
247
+ const choice = capabilityRuleOfSelection(override)
248
+ return Object.keys(choice).length === 0 ? undefined : choice
249
+ }
250
+
251
+ /**
252
+ * This session's statement as its newest delivered plan recorded it.
253
+ *
254
+ * Only a plan that delivered at least one choice is a statement: an empty
255
+ * override map says the session inherited everything, which is the state a
256
+ * session without a record already reads as, not a decision to migrate. A plan
257
+ * whose overrides cannot be read as this vocabulary is `unreadable` for the same
258
+ * reason a record is: it supersedes what came before it and this build cannot
259
+ * say what it decided.
260
+ */
261
+ function planSelection(message) {
262
+ const snapshot = message?.source?.snapshot
263
+ const overrides = snapshot?.sessionOverrides
264
+ // A snapshot that states no choices at all (the v1 generation) recorded nothing to
265
+ // migrate. One that states them in a shape this vocabulary cannot read is
266
+ // `unreadable`: it supersedes what came before it, and reporting `none` would let
267
+ // the composition withdraw a decision the log still holds.
268
+ if (overrides === undefined) return Object.freeze({ state: 'none' })
269
+ if (!isRecord(overrides)) return Object.freeze({ state: 'unreadable' })
270
+ try {
271
+ // The entries are collected as pairs and materialized with own data properties: an
272
+ // override key the record vocabulary cannot name (for example `__proto__`) must reach
273
+ // the vocabulary check as a name it refuses, not silently become the map's prototype
274
+ // and leave the statement reading `none` where the record branch reads `unreadable`.
275
+ const entries = []
276
+ for (const [id, override] of sortedEntries(overrides)) {
277
+ const choice = planChoice(override)
278
+ if (choice !== undefined) entries.push([id, choice])
279
+ }
280
+ if (entries.length === 0) return Object.freeze({ state: 'none' })
281
+ return Object.freeze({ state: 'decided', legacy: true,
282
+ selection: normalizeCapabilitySelection({ schema: CAPABILITY_SELECTION_SCHEMA, choices: Object.fromEntries(entries) }) })
283
+ } catch {
284
+ return Object.freeze({ state: 'unreadable' })
285
+ }
286
+ }
287
+
288
+ /**
289
+ * How this session's newest statement reads, from its own log events.
290
+ *
291
+ * One entry answers for every consumer: a record when the session wrote one,
292
+ * otherwise the newest delivered plan as the equivalent form. Reading the plan
293
+ * only when no record exists keeps the record the newest word, so a session that
294
+ * upgraded and then decided again is not answered from an older plan.
295
+ *
296
+ * A caller that knows which session the log belongs to passes that identity, and a
297
+ * delivered plan that names a different session is then not read as this session's
298
+ * statement. A plan message carries the session it was composed for, and the writers
299
+ * that restore one require that identity; a record carries none, so the log it was
300
+ * appended to is its only identity.
301
+ */
302
+ export function capabilitySelectionOf(events, sessionId) {
303
+ const records = []
304
+ let delivered
305
+ for (const event of Array.isArray(events) ? events : []) {
306
+ // A record is the statement itself; the message form is the same statement on a release
307
+ // that wrote one. A repaired statement keeps its wording and loses only the carrier, so all
308
+ // three take part in the same "newest statement wins" ordering — but only while the payload
309
+ // is this owner's own form, because the repair retires every owner's surface node.
310
+ if (event?.type === CAPABILITY_SELECTION_RECORD || event?.type === RETIRED_SURFACE_RECORD) {
311
+ if (!statesCapabilitySelection(event)) continue
312
+ records.push({ form: CAPABILITY_SELECTION_FORM, choices: readCapabilitySelectionStatement(event)?.choices })
313
+ continue
314
+ }
315
+ if (event?.type !== 'user/message') continue
316
+ const source = event.data?.source
317
+ if (!isPtcMessageSource(source)) continue
318
+ if (source.form === CAPABILITY_SELECTION_FORM) {
319
+ records.push({ form: CAPABILITY_SELECTION_FORM, choices: readCapabilitySelection(event.data)?.choices })
320
+ } else if (source.form === CAPABILITY_PLAN_FORM
321
+ && (sessionId === undefined || source.sessionId === sessionId)) delivered = event.data
322
+ }
323
+ const recorded = classifyCapabilitySelection(records)
324
+ return recorded.state === 'none' ? planSelection(delivered) : recorded
325
+ }
326
+
327
+ /** The choice this session recorded for one entry, or `undefined` when it recorded none. */
328
+ export function capabilityChoiceOf(statement, id) {
329
+ return statement?.state === 'decided' ? statement.selection.choices[id] : undefined
330
+ }
@@ -130,10 +130,10 @@ export const CONFIG_FIELDS = Object.freeze([
130
130
  type: 'enum',
131
131
  options: Object.freeze(['stateful', 'protected']),
132
132
  default: 'stateful',
133
- label: '允许重声明和覆盖',
134
- labelEn: 'Allow redeclarations and overrides',
135
- description: '同一作用域的声明和赋值可更新变量、函数、类及导入。关闭后检测跨 cell 重声明,并保护新建的 const 和 import;静态 import/export 始终可用。',
136
- descriptionEn: 'Declarations and assignments update variables, functions, classes and imports in the same scope. Disabling detects cross-cell redeclarations and protects newly created const and import bindings; static import/export remains available.',
133
+ label: '跨 cell 声明策略',
134
+ labelEn: 'Cross-cell declaration policy',
135
+ description: '同一作用域的声明和赋值可更新变量、函数、类及导入。选“启用名称保护”时改为检测跨 cell 重声明,并保护新建的 const 和 import;静态 import/export 始终可用。',
136
+ descriptionEn: 'Declarations and assignments update variables, functions, classes and imports in the same scope. With “Protect names” selected, cross-cell redeclarations are detected instead and newly created const and import bindings are protected; static import/export remains available.',
137
137
  },
138
138
  {
139
139
  key: 'canonicalizeToolCalls',
@@ -287,6 +287,15 @@ export const CONFIG_FIELDS = Object.freeze([
287
287
  description: '检测到官方持久 REPL 模式时由官方运行时接管 PTC 会话执行;PTC Plus 让出 run_code 及其进程内会话状态语义,关闭时始终由 PTC Plus 接管。',
288
288
  descriptionEn: 'Lets the official runtime own PTC session execution when the official persistent REPL mode is active; PTC Plus then leaves run_code and its in-process session-state semantics to DSH. Disabled keeps PTC Plus in charge.',
289
289
  },
290
+ {
291
+ key: 'editRewritesHistory',
292
+ type: 'boolean',
293
+ default: false,
294
+ label: '编辑时替换原单元格',
295
+ labelEn: 'Replace the edited cell in place',
296
+ description: '默认关闭,一般也不需要打开:编辑只会追加一次执行,原单元格和它创建的绑定都还在。开启后,编辑会替换原单元格,之后的重建按新版本进行,新版本里不再声明的名字会被点名"已被替换",而不是悄悄消失。只有当你希望环境表现得"这一格一直就是新版本"时才需要打开。',
297
+ descriptionEn: 'Off by default, and usually unnecessary: an edit appends one more execution, leaving the original cell and the bindings it created in place. When on, the edit replaces the original cell, later rebuilds follow the new version, and a name the new version no longer declares is reported as replaced rather than silently disappearing. Turn it on only when the environment should behave as if that cell had always been the new version.',
298
+ },
290
299
  {
291
300
  key: 'tipsEnabled',
292
301
  type: 'boolean',
@@ -483,7 +492,7 @@ export const CONFIG_GROUPS = Object.freeze([
483
492
  key: 'recovery',
484
493
  label: '状态与恢复',
485
494
  labelEn: 'State and recovery',
486
- fields: Object.freeze(['durableReplay', 'unmeasuredCellLimit', 'officialReplPriority', 'tipsEnabled', 'tipCooldownMessages', 'tipEscalationFailures']),
495
+ fields: Object.freeze(['durableReplay', 'editRewritesHistory', 'unmeasuredCellLimit', 'officialReplPriority', 'tipsEnabled', 'tipCooldownMessages', 'tipEscalationFailures']),
487
496
  },
488
497
  {
489
498
  key: 'extensions',
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module'
5
5
  import { dirname, isAbsolute, join } from 'node:path'
6
6
  import { isDeepStrictEqual } from 'node:util'
7
7
  import { RUN_CODE } from './runtime-bridge-owner.js'
8
+ import { sessionExecutionAvailable } from './host-tool-sdk.js'
8
9
 
9
10
  const CORDIS_PRESET_ID = 'cordis'
10
11
  const CORDIS_SKILL_NAME = 'cordis-plugin-development'
@@ -111,7 +112,7 @@ function requireFiberServices(agent, fiber, owner) {
111
112
  function absentCompanionSurfaces(agent) {
112
113
  if (typeof agent?.ctx?.get !== 'function') return undefined
113
114
  const missing = []
114
- if (agent.ctx.tools?.get?.(RUN_CODE, agent) === undefined) missing.push(`the ${RUN_CODE} tool`)
115
+ if (!sessionExecutionAvailable(agent.ctx.tools, agent, RUN_CODE)) missing.push(`the ${RUN_CODE} tool`)
115
116
  if (agent.ctx.tools?.get?.(COMPANION_SKILL_TOOL, agent) === undefined) {
116
117
  missing.push(`the ${COMPANION_SKILL_TOOL} tool`)
117
118
  }
@@ -27,7 +27,7 @@ const INVALID_TOOL_JSON_FAILURE = Object.freeze({
27
27
  const MALFORMED_TOOL_REJECTION_ID = 'dsh-ptc-plus-malformed-tool-json'
28
28
  const MALFORMED_TOOL_REJECTION_ARGUMENTS = '{"dsh_ptc_plus_malformed_tool_json":'
29
29
 
30
- function adaptRunCodeSchema(tool, promptText) {
30
+ export function adaptRunCodeSchema(tool, promptText) {
31
31
  const parameters = tool.parameters
32
32
  const properties = isRecord(parameters) ? parameters.properties : undefined
33
33
  const code = isRecord(properties) ? properties.code : undefined
@@ -109,9 +109,14 @@ export function createCordisRecoveryPolicy(initiallyEnabled) {
109
109
  })
110
110
  }
111
111
 
112
- function presentationState(assembly) {
113
- const tools = Array.isArray(assembly?.tools) ? assembly.tools : []
114
- if (!tools.some(tool => tool?.name === RUN_CODE)) {
112
+ function presentationState(assembly, declaresRunCode, agent) {
113
+ const tools = Array.isArray(assembly?.tools) ? assembly.tools : undefined
114
+ // A host that declares tools after the presentation waterfall carries no
115
+ // array here; the transport it publishes is then the same fact.
116
+ const present = tools === undefined
117
+ ? declaresRunCode?.(agent) === true
118
+ : tools.some(tool => tool?.name === RUN_CODE)
119
+ if (!present) {
115
120
  return { presentation: 'native', ownerProven: false, collapseSectionName: undefined }
116
121
  }
117
122
  const collapse = PTC_COLLAPSE_SECTION_NAMES
@@ -453,6 +458,7 @@ export function createDirectSurfaceOwner({
453
458
  canonicalizeToolCalls,
454
459
  sessionId,
455
460
  toolSchemasForAgent,
461
+ declaresRunCode,
456
462
  userBindingsForAgent = async () => undefined,
457
463
  setAgentPresentation = async () => {},
458
464
  sessionLogView,
@@ -578,7 +584,7 @@ export function createDirectSurfaceOwner({
578
584
  const requestOwner = id === undefined ? undefined : sessionOwner(id)
579
585
  const isCurrent = () => !disposed && (requestOwner === undefined
580
586
  || (requestOwner.active && sessions.get(id) === requestOwner))
581
- const initialState = presentationState(initialAssembly)
587
+ const initialState = presentationState(initialAssembly, declaresRunCode, agent)
582
588
  let composition = compositions.get(agent)
583
589
  if (composition === undefined && id !== undefined
584
590
  && (initialState.ownerProven || initialState.presentation === 'ptc')) {
@@ -590,11 +596,17 @@ export function createDirectSurfaceOwner({
590
596
 
591
597
  const assembly = await next()
592
598
  if (!isCurrent()) return assembly
599
+ // A host may declare the model-facing tool array after this waterfall, in
600
+ // which case the assembly carries none. The declaration surface is then
601
+ // host-owned: the plugin still owns presentation, sections, contexts and
602
+ // request policy, but it must not claim to have replaced declarations the
603
+ // host has not asked it for. A present but non-array declaration is still
604
+ // a malformed assembly.
593
605
  const tools = assembly.tools
594
- if (!Array.isArray(tools)) {
606
+ if (tools !== undefined && !Array.isArray(tools)) {
595
607
  throw new Error('ptc-plus: incompatible prompt assembly; expected a tools array')
596
608
  }
597
- const completedState = presentationState(assembly)
609
+ const completedState = presentationState(assembly, declaresRunCode, agent)
598
610
  if (composition === undefined && id !== undefined) {
599
611
  const state = initialState.ownerProven ? initialState : completedState
600
612
  composition = captureComposition(agent, id, state.presentation)
@@ -607,7 +619,7 @@ export function createDirectSurfaceOwner({
607
619
  const requestSignal = context?.signal !== null && typeof context?.signal === 'object'
608
620
  ? context.signal
609
621
  : undefined
610
- if (!tools.some(tool => tool?.name === RUN_CODE)) {
622
+ if (completedState.presentation === 'native') {
611
623
  if (presentation !== 'native') {
612
624
  throw new Error(`ptc-plus: ${presentation} agent composition assembled without run_code`)
613
625
  }
@@ -625,15 +637,25 @@ export function createDirectSurfaceOwner({
625
637
  if (!isCurrent()) return assembly
626
638
  const sessionPtc = presentation !== 'native' && id !== undefined
627
639
  const sessionPtcProjection = presentation === 'ptc' && id !== undefined
628
- if (sessionPtc) await capabilityEnvironment?.prepare(agent, assembly.sections)
640
+ // The step's admission is the delivered capability set: the Host renders its own
641
+ // `tools:sdk` block from it and builds a program's `tools` members from it, so a
642
+ // declaration composed from the registered-definitions view can name a member this
643
+ // step does not deliver. Reading it here keeps the declaration, the plan and the
644
+ // program about one set; a host that publishes no admission states no set, and the
645
+ // registry view is then the only evidence left.
646
+ const delivered = sessionPtc && context?.toolSet !== undefined
647
+ ? toolSchemasForAgent(agent, context.toolSet) : undefined
648
+ if (sessionPtc) await capabilityEnvironment?.prepare(agent, assembly.sections, false, undefined, delivered)
629
649
  const userBindings = sessionPtc ? await userBindingsForAgent(agent) : undefined
630
650
  if (!isCurrent()) return assembly
631
- const runCode = tools.find(tool => tool?.name === RUN_CODE)
651
+ const runCode = tools?.find(tool => tool?.name === RUN_CODE)
632
652
  const promptText = currentPromptText
633
- let directTools = sessionPtc ? capabilityEnvironment?.visibleSchemas(agent, tools) ?? tools : tools
653
+ const hostDeclaresTools = tools !== undefined
654
+ let directTools = tools === undefined ? undefined
655
+ : sessionPtc ? capabilityEnvironment?.visibleSchemas(agent, tools) ?? tools : tools
634
656
  if (sessionPtcProjection) {
635
657
  editTransport.ensureInstalled(agent)
636
- directTools = [adaptRunCodeSchema(runCode, promptText)]
658
+ if (hostDeclaresTools) directTools = [adaptRunCodeSchema(runCode, promptText)]
637
659
  }
638
660
  const runtimeContexts = sessionPtc
639
661
  ? sessionRuntimeContexts(agent, tipConfig, {
@@ -654,9 +676,9 @@ export function createDirectSurfaceOwner({
654
676
  && assembly.sections.some(section => section?.name === 'tools:sdk'
655
677
  || (sessionPtcProjection && section?.name === completedState.collapseSectionName))
656
678
  const sections = projectsSections
657
- ? (capabilityEnvironment?.projectSections(agent, assembly.sections) ?? assembly.sections).map(section => {
679
+ ? (capabilityEnvironment?.projectSections(agent, assembly.sections, delivered) ?? assembly.sections).map(section => {
658
680
  if (section?.name === 'tools:sdk') {
659
- return { ...section, text: capabilitySdk(capabilityEnvironment?.sdk(agent, section.text) ?? section.text,
681
+ return { ...section, text: capabilitySdk(capabilityEnvironment?.sdk(agent, section.text, delivered) ?? section.text,
660
682
  userBindings !== undefined, languageSemantics) }
661
683
  }
662
684
  if (sessionPtcProjection && section?.name === completedState.collapseSectionName) {
@@ -666,8 +688,13 @@ export function createDirectSurfaceOwner({
666
688
  })
667
689
  : assembly.sections
668
690
  if (id !== undefined) {
691
+ // A top-level call is repaired into a program call only for a member this step
692
+ // delivers: the admission is what the declaration and the program state, so a
693
+ // name the registry merely carries is a call no member answers and no schema
694
+ // proves. A step that published no admission keeps the registry view.
695
+ const callable = delivered ?? toolSchemasForAgent(agent)
669
696
  const nativeSchemas = presentation === 'ptc'
670
- ? new Map((capabilityEnvironment?.visibleSchemas(agent, toolSchemasForAgent(agent)) ?? toolSchemasForAgent(agent))
697
+ ? new Map((capabilityEnvironment?.visibleSchemas(agent, callable) ?? callable)
671
698
  .filter(schema => typeof schema?.name === 'string'
672
699
  && schema.name !== RUN_CODE && schema.name !== EDIT_RUN_CODE)
673
700
  .map(schema => [schema.name, schema]))
@@ -685,11 +712,11 @@ export function createDirectSurfaceOwner({
685
712
 
686
713
  return {
687
714
  ...assembly,
688
- tools: sessionPtcProjection
715
+ ...(directTools === undefined ? {} : { tools: sessionPtcProjection
689
716
  ? directTools
690
717
  : directTools.map(tool => tool?.name === RUN_CODE
691
718
  ? adaptRunCodeSchema(tool, promptText)
692
- : tool),
719
+ : tool) }),
693
720
  sections,
694
721
  }
695
722
  },
@@ -12,7 +12,7 @@ import {
12
12
  normalizeRecoveryBoundaries,
13
13
  normalizeRewrites,
14
14
  validatedRewrites,
15
- withDiagnosticWording,
15
+ withSettlementProof,
16
16
  } from './session-journal.js'
17
17
  import {
18
18
  EXPECTED_TARGET_CALL_SEQ,
@@ -29,6 +29,7 @@ import { sessionEvents } from './session-events.js'
29
29
  import { deepEqualJson, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
30
30
  import { scopeTarget } from '@deepseek-ai/dsh-scope'
31
31
  import { RUN_CODE } from './runtime-bridge-owner.js'
32
+ import { dispatchToolCall } from './host-tool-sdk.js'
32
33
  import { CAPABILITY_EVIDENCE_KEY } from './capability-environment.js'
33
34
  import { isRecord } from './record-utils.js'
34
35
  import {
@@ -199,9 +200,9 @@ export function createEditTransportOwner(ctx, {
199
200
  derived.draftCapability,
200
201
  presentationGeneration,
201
202
  )
202
- // The derived settlement records its own diagnostic wording, so a replay reads
203
- // the same pairing the root path publishes.
204
- const withWording = withDiagnosticWording(withDraft, withDraft[JOURNAL_KEY])
203
+ // The derived settlement proves its own facts, so a replay reads the same
204
+ // settlement the root path publishes.
205
+ const withWording = withSettlementProof(withDraft, withDraft[JOURNAL_KEY])
205
206
  return derived.replMemory === undefined
206
207
  ? withWording
207
208
  : withReplMemorySnapshot(withWording, derived.replMemory, presentationGeneration)
@@ -310,7 +311,7 @@ export function createEditTransportOwner(ctx, {
310
311
  const executionStart = agent.session.append('tool/ptc-dispatch-start', data)
311
312
  const derivedExec = { callId: data.subCallId, rootCallId: data.rootCallId,
312
313
  parent: rootExec.token, name: RUN_CODE, arguments: argumentsValue, agent, signal: rootSignal }
313
- const dispatch = () => ctx.tools.execute(derivedExec)
314
+ const dispatch = () => dispatchToolCall(ctx.tools, derivedExec)
314
315
  const tentative = await executeTentative(executionStart.seq, start.rootCallSeq,
315
316
  dispatch, executionLog, derivedExec)
316
317
  const inner = tentative.result
@@ -1,4 +1,5 @@
1
1
  import { createExecutionSeam, EXECUTION_SEAM_SERVICE, LEGACY_EXECUTION_SEAM_SERVICE } from './execution-seam-compat.js'
2
+ import { providerRetainsState } from './official-repl-priority.js'
2
3
 
3
4
  export const name = 'ptc-plus-execution-provider'
4
5
  export const inject = ['loader']
@@ -70,6 +71,11 @@ export function apply(ctx, config = {}) {
70
71
  get language() { return readOriginal().language },
71
72
  get isolation() { return readOriginal().isolation },
72
73
  get programFormat() { return readOriginal().programFormat ?? 'function-body' },
74
+ // This composition isolates state owners and forwards every run to the
75
+ // original provider, so the original's retention declaration is the
76
+ // truthful answer. A host plugin that reads it must not see a stateful
77
+ // environment reported as stateless.
78
+ get retainsState() { return providerRetainsState(readOriginal()) },
73
79
  get executionInstructions() { return readOriginal().executionInstructions },
74
80
  get sandboxMode() { return readOriginal().sandboxMode },
75
81
  get timeout() { return readOriginal().timeout },