dsh-ptc-plus 0.2.1

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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/README.zh.md +137 -0
  4. package/assets/dsh-ptc-plus-banner.webp +0 -0
  5. package/assets/ptc-plus-repair.png +0 -0
  6. package/assets/ptc-plus-settings.png +0 -0
  7. package/client.js +366 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/README.md +21 -0
  10. package/docs/adr/0001-delegate-governance-to-dsh.md +3 -0
  11. package/docs/adr/0002-limit-work-map-scope.md +3 -0
  12. package/docs/adr/0003-prefer-native-program-surfaces.md +3 -0
  13. package/docs/adr/0004-declare-program-bindings-at-the-host.md +5 -0
  14. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +129 -0
  15. package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +45 -0
  16. package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +39 -0
  17. package/docs/adr/0008-define-agent-operating-contract.md +25 -0
  18. package/docs/adr/0009-bound-expensive-acceptance-default.md +39 -0
  19. package/docs/adr/0010-session-log-derived-recovery-tips.md +26 -0
  20. package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +20 -0
  21. package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +23 -0
  22. package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +21 -0
  23. package/docs/adr/0014-persist-cell-rewrite-policy.md +25 -0
  24. package/docs/adr/0015-preserve-source-positions-through-rewrites.md +25 -0
  25. package/docs/adr/0016-archive-resolved-review-findings.md +43 -0
  26. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +23 -0
  27. package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +25 -0
  28. package/docs/adr/0019-plugin-settings-and-kill-switch.md +32 -0
  29. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +36 -0
  30. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +68 -0
  31. package/docs/adr/README.md +18 -0
  32. package/docs/architecture.md +134 -0
  33. package/docs/capability-projection.md +48 -0
  34. package/docs/client-ui.md +47 -0
  35. package/docs/durability-design.md +265 -0
  36. package/docs/evaluation.md +62 -0
  37. package/docs/installation.md +82 -0
  38. package/docs/program-data-plane.md +61 -0
  39. package/docs/publishing.md +37 -0
  40. package/docs/runtime-reference.md +119 -0
  41. package/docs/value-wire.md +61 -0
  42. package/index.js +532 -0
  43. package/internal/ast-traversal.js +20 -0
  44. package/internal/binding-descriptors.js +95 -0
  45. package/internal/binding-pattern.js +54 -0
  46. package/internal/builtin-esm-sync.js +9 -0
  47. package/internal/cell-analysis.js +467 -0
  48. package/internal/cell-rewriter.js +520 -0
  49. package/internal/config-spec.js +179 -0
  50. package/internal/cordis-tools-owner.js +363 -0
  51. package/internal/diagnostic.js +133 -0
  52. package/internal/direct-surface-owner.js +326 -0
  53. package/internal/edit-transport-owner.js +287 -0
  54. package/internal/failure-reporting.js +139 -0
  55. package/internal/kernel-worker.js +484 -0
  56. package/internal/module-policy.js +46 -0
  57. package/internal/program-bindings.js +187 -0
  58. package/internal/record-utils.js +53 -0
  59. package/internal/recovery-tips.js +81 -0
  60. package/internal/rejected-cell-editor.js +328 -0
  61. package/internal/repl-convenience.js +201 -0
  62. package/internal/runtime-bridge-owner.js +417 -0
  63. package/internal/runtime-config.js +77 -0
  64. package/internal/runtime-contexts.js +31 -0
  65. package/internal/sdk-projection.js +88 -0
  66. package/internal/session-cell-executor.js +612 -0
  67. package/internal/session-journal.js +889 -0
  68. package/internal/session-log-view.js +80 -0
  69. package/internal/session-runtime.js +536 -0
  70. package/internal/session-state.js +45 -0
  71. package/internal/source-position-map.js +224 -0
  72. package/internal/tool-call-canonicalizer.js +339 -0
  73. package/internal/value-wire.js +421 -0
  74. package/internal/worker-client.js +218 -0
  75. package/internal/worker-cwd-virtualization.js +155 -0
  76. package/package.json +100 -0
  77. package/src/client.js +165 -0
package/index.js ADDED
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Session-bound REPL for DeepSeek Harness PTC mode.
3
+ *
4
+ * DSH's run_code bridge does not pass session identity to CodeRuntime.run().
5
+ * The tools/execute around-hook carries that identity into the runtime bridge,
6
+ * which redirects only those runs to a persistent per-session kernel.
7
+ */
8
+
9
+ import Schema from '@deepseek-ai/schemastery'
10
+ import { createDirectSurfaceOwner } from './internal/direct-surface-owner.js'
11
+ import { createCordisToolsOwner } from './internal/cordis-tools-owner.js'
12
+ import { createEditTransportOwner, EDIT_RUN_CODE } from './internal/edit-transport-owner.js'
13
+ import { createRuntimeBridgeOwner, RUN_CODE } from './internal/runtime-bridge-owner.js'
14
+ import { resolveConfig } from './internal/runtime-config.js'
15
+ import {
16
+ CONFIG_FIELDS,
17
+ SETTINGS_NAMESPACE,
18
+ } from './internal/config-spec.js'
19
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
20
+
21
+ const INSTALL_CLEANUP = Symbol('ptc-plus install cleanup')
22
+
23
+ /** Plugin name used by loader diagnostics. */
24
+ export const name = 'ptc-plus'
25
+
26
+ /** Runtime limits and behavior exposed to Cordis configuration. */
27
+ function configSchemaField(field) {
28
+ const base = field.type === 'boolean'
29
+ ? Schema.boolean().default(field.default)
30
+ : Schema.number().step(1).min(field.min).max(field.max).default(field.default)
31
+ return base.description(field.description)
32
+ }
33
+
34
+ export const Config = Schema.object(Object.fromEntries(
35
+ CONFIG_FIELDS.map(field => [field.key, configSchemaField(field)]),
36
+ ))
37
+
38
+ /** Services required by the plugin. */
39
+ export const inject = ['tools', 'codeRuntime', 'systemPrompt', 'agents', 'llm']
40
+
41
+ function replGuidance(
42
+ looseTopLevelRedeclarations,
43
+ durableReplay,
44
+ autoRewriteImports,
45
+ autoStripExports,
46
+ autoSplitRedeclarations,
47
+ cordisToolsEnabled,
48
+ ) {
49
+ const redeclaration = looseTopLevelRedeclarations
50
+ ? 'Repeated top-level `const`/`let` declarations replace existing bindings.'
51
+ : 'Redeclaring an existing top-level name fails before execution, so reuse it or place one-off declarations inside a block.'
52
+ const moduleSyntax = autoRewriteImports && autoStripExports
53
+ ? 'static `import` declarations are adapted with live, read-only bindings and top-level `export` modifiers are stripped automatically.'
54
+ : autoRewriteImports
55
+ ? 'static `import` declarations are adapted with live, read-only bindings; top-level `export` modifiers remain unsupported.'
56
+ : autoStripExports
57
+ ? 'top-level `export` modifiers are stripped automatically; static `import` declarations remain unsupported.'
58
+ : 'static `import` declarations and top-level `export` modifiers remain unsupported.'
59
+ const splitSyntax = autoSplitRedeclarations
60
+ ? 'Mixed new/existing top-level destructuring is split automatically while preserving assignment semantics.'
61
+ : 'Mixed new/existing top-level destructuring remains unsupported; separate the declaration from the assignment.'
62
+ const recovery = durableReplay
63
+ ? 'Direct Node/OS access remains live but is not replayed after a kernel restart.'
64
+ : 'Durable replay is disabled for this profile. Bindings remain reusable only in the current process; a new kernel starts empty.'
65
+ const cordisRecovery = cordisToolsEnabled
66
+ ? 'When using Cordis tools, keep large host or client source in a top-level binding before the Cordis call. A Cordis parse or validation error is a runtime failure: bindings assigned before that failure remain live, so retry only the Cordis call with the existing binding instead of resending the source. Treat that binding as the read-only input for the retry.'
67
+ : ''
68
+ return `\`run_code\` continues one persistent PTC REPL. Ordinary top-level bindings remain available to later cells, so reuse them instead of resending setup code. Choose the smallest cell that answers the request and return only the value the next step needs.
69
+
70
+ The host may append a bounded recovery context after a qualifying failure. Treat that context as a session-log-derived diagnostic: use \`edit_run_code\` only when it explicitly proves a complete-cell rerun is safe; otherwise inspect live state in a new short \`run_code\` cell.
71
+
72
+ ## Cell conventions
73
+ Expressions that are neither returned nor printed produce no output. Keep large inspection results in bindings or reduce them to targeted excerpts: \`tools.read\` is bounded inspection, not a lossless whole-file reader. Cells are async function bodies; ${moduleSyntax} Use dynamic import or require explicitly when static module syntax is unsupported. ${redeclaration} ${splitSyntax}
74
+
75
+ ## Available capabilities
76
+ Use \`capabilities.tree()\`, \`capabilities.find()\`, and \`capabilities.inspect()\` to discover the current request's live \`tools.*\` members before calling an unfamiliar binding. Prefer direct current-cell work; reserve \`code.run\` for source already held as data.
77
+
78
+ Native tool availability, executable names, shells, and path syntax depend on the current DSH profile and execution world; inspect them instead of assuming Windows, WSL, POSIX, or a particular shell. ${recovery}${cordisToolsEnabled ? ` ${cordisRecovery}` : ''}`
79
+ }
80
+
81
+ /** Register the session-bound REPL runtime. */
82
+ function installPtCRuntime(ctx, resolvedConfig, toolSchemasForAgent, sessionId) {
83
+ let activeConfig = resolvedConfig
84
+ let cordisTools
85
+ let runtimeBridge
86
+ let editTransport
87
+ let directSurface
88
+ let ready
89
+ let pendingCordisActivation
90
+ const disposers = []
91
+ let disposed = false
92
+ async function dispose() {
93
+ if (disposed) return
94
+ disposed = true
95
+ pendingCordisActivation?.cancel()
96
+ const failures = []
97
+ for (const dispose of [...disposers].reverse()) {
98
+ if (typeof dispose !== 'function') continue
99
+ try {
100
+ await dispose()
101
+ } catch (error) {
102
+ failures.push(error)
103
+ }
104
+ }
105
+ for (const owner of [directSurface, editTransport, runtimeBridge, cordisTools]) {
106
+ try {
107
+ await owner?.dispose()
108
+ } catch (error) {
109
+ failures.push(error)
110
+ }
111
+ }
112
+ if (failures.length > 0) throw new AggregateError(failures, 'ptc-plus runtime disposal failed')
113
+ }
114
+ const awaitCordisToolsOwner = async (owner, cancelled) => {
115
+ try {
116
+ await Promise.race([owner.ready, cancelled])
117
+ return owner
118
+ } catch (error) {
119
+ let rollbackError
120
+ try {
121
+ await owner.dispose()
122
+ } catch (caught) {
123
+ rollbackError = caught
124
+ }
125
+ if (cordisTools === owner) cordisTools = undefined
126
+ if (rollbackError !== undefined) {
127
+ throw new AggregateError(
128
+ [error, rollbackError],
129
+ 'ptc-plus: Cordis activation and rollback failed',
130
+ { cause: error },
131
+ )
132
+ }
133
+ throw error
134
+ }
135
+ }
136
+ const activateCordisToolsOwner = async () => {
137
+ const owner = createCordisToolsOwner(ctx)
138
+ let cancel
139
+ const cancelled = new Promise((_resolve, reject) => {
140
+ cancel = () => reject(new Error('ptc-plus: Cordis activation cancelled'))
141
+ })
142
+ const activation = { owner, cancel }
143
+ cordisTools = owner
144
+ pendingCordisActivation = activation
145
+ try {
146
+ return await awaitCordisToolsOwner(owner, cancelled)
147
+ } finally {
148
+ if (pendingCordisActivation === activation) pendingCordisActivation = undefined
149
+ }
150
+ }
151
+ const cancelCordisActivation = () => pendingCordisActivation?.cancel()
152
+ try {
153
+ cordisTools = activeConfig.cordisToolsEnabled
154
+ ? createCordisToolsOwner(ctx)
155
+ : undefined
156
+ if (cordisTools !== undefined) {
157
+ const initialCordisTools = cordisTools
158
+ ready = awaitCordisToolsOwner(initialCordisTools, new Promise(() => {})).catch(error => {
159
+ if (cordisTools === initialCordisTools) cordisTools = undefined
160
+ throw error
161
+ })
162
+ }
163
+ runtimeBridge = createRuntimeBridgeOwner({
164
+ ctx,
165
+ sessionConfig: activeConfig,
166
+ maxNestedRunCodeDepth: activeConfig.maxNestedRunCodeDepth,
167
+ sessionId,
168
+ toolSchemasForAgent,
169
+ })
170
+ editTransport = createEditTransportOwner(ctx, {
171
+ durableReplay: activeConfig.durableReplay,
172
+ executeTentative: runtimeBridge.executeTentative,
173
+ sessionId,
174
+ toolSchemasForAgent,
175
+ })
176
+ directSurface = createDirectSurfaceOwner({
177
+ editTransport,
178
+ runtimeConfig: activeConfig,
179
+ canonicalizeToolCalls: activeConfig.canonicalizeToolCalls,
180
+ sessionId,
181
+ toolSchemasForAgent,
182
+ })
183
+ disposers.push(ctx.systemPrompt.section({
184
+ name: 'tools:ptc-plus-repl',
185
+ order: 98,
186
+ text: context => {
187
+ if (ctx.tools.get(RUN_CODE, context?.scope) === undefined) return ''
188
+ return replGuidance(
189
+ activeConfig.looseTopLevelRedeclarations,
190
+ activeConfig.durableReplay,
191
+ activeConfig.autoRewriteImports,
192
+ activeConfig.autoStripExports,
193
+ activeConfig.autoSplitRedeclarations,
194
+ activeConfig.cordisToolsEnabled,
195
+ )
196
+ },
197
+ }))
198
+ disposers.push(ctx.on('system-prompt/assemble', (assembly, context, next) => (
199
+ directSurface.assemble(assembly, context, next)
200
+ )))
201
+ disposers.push(ctx.on('llm/stream', (options, next) => directSurface.stream(options, next), { global: true }))
202
+ disposers.push(ctx.on('tools/execute', (exec, next) => {
203
+ const rejected = directSurface.executionRejection(exec)
204
+ if (rejected !== undefined) return rejected
205
+ if (exec.name === RUN_CODE) return runtimeBridge.handleExecute(exec, next)
206
+ return next()
207
+ }))
208
+ disposers.push(ctx.on('tools/result', (exec, result) => {
209
+ directSurface.handleResult(exec)
210
+ if (exec.name === EDIT_RUN_CODE) return editTransport.handleResult(exec, result)
211
+ if (exec.name === RUN_CODE) return runtimeBridge.handleResult(exec, result)
212
+ }))
213
+ disposers.push(ctx.on('agent/disposed', ({ agent }) => {
214
+ directSurface.disposeAgent(agent)
215
+ editTransport.disposeAgent(agent)
216
+ return runtimeBridge.disposeAgent(agent)
217
+ }))
218
+ disposers.push(ctx.on('session/disposed', (session) => {
219
+ directSurface.disposeSession(session)
220
+ editTransport.disposeSession(session)
221
+ return runtimeBridge.disposeSession(session)
222
+ }))
223
+ } catch (error) {
224
+ const cleanup = dispose()
225
+ if (error !== null && typeof error === 'object') {
226
+ Object.defineProperty(error, INSTALL_CLEANUP, { value: cleanup })
227
+ }
228
+ throw error
229
+ }
230
+ async function reconfigure(nextConfig) {
231
+ if (disposed) return
232
+ const previousConfig = activeConfig
233
+ const rollbacks = []
234
+ try {
235
+ runtimeBridge.reconfigure(nextConfig)
236
+ rollbacks.push(() => runtimeBridge.reconfigure(previousConfig))
237
+ editTransport.reconfigure(nextConfig)
238
+ rollbacks.push(() => editTransport.reconfigure(previousConfig))
239
+ directSurface.reconfigure(nextConfig)
240
+ rollbacks.push(() => directSurface.reconfigure(previousConfig))
241
+
242
+ if (nextConfig.cordisToolsEnabled && cordisTools === undefined) {
243
+ await activateCordisToolsOwner()
244
+ } else if (!nextConfig.cordisToolsEnabled && cordisTools !== undefined) {
245
+ const currentCordis = cordisTools
246
+ try {
247
+ await currentCordis.dispose()
248
+ cordisTools = undefined
249
+ } catch (error) {
250
+ try {
251
+ await activateCordisToolsOwner()
252
+ /* c8 ignore next */
253
+ } catch (rollbackError) {
254
+ cordisTools = currentCordis
255
+ throw new AggregateError([error, rollbackError], 'ptc-plus: Cordis reconfiguration and rollback failed', { cause: error })
256
+ }
257
+ throw error
258
+ }
259
+ }
260
+ activeConfig = nextConfig
261
+ } catch (error) {
262
+ const rollbackFailures = []
263
+ /* c8 ignore next -- the rollback loop's rejection branch is host-specific. */
264
+ for (const rollback of rollbacks.reverse()) {
265
+ try {
266
+ await rollback()
267
+ /* c8 ignore next */
268
+ } catch (rollbackError) { rollbackFailures.push(rollbackError) }
269
+ }
270
+ /* c8 ignore next */
271
+ if (rollbackFailures.length > 0) { throw new AggregateError([error, ...rollbackFailures], 'ptc-plus: live runtime reconfiguration rollback failed', { cause: error }) }
272
+ throw error
273
+ }
274
+ }
275
+
276
+ return Object.freeze({ cancelCordisActivation, dispose, reconfigure, ready })
277
+ }
278
+
279
+ /** Register the session-bound REPL runtime. */
280
+ export function apply(ctx, config = {}) {
281
+ const resolvedConfig = resolveConfig(config)
282
+ const toolSchemasForAgent = agent => typeof ctx.tools.schemas === 'function'
283
+ ? ctx.tools.schemas(agent)
284
+ : []
285
+ const sessionId = agent => {
286
+ const id = agent?.session?.id ?? agent?.id
287
+ return id === undefined ? undefined : String(id)
288
+ }
289
+ let committed
290
+ let activating
291
+ let disposed = false
292
+ let transitionTail = Promise.resolve()
293
+ let pendingTransitions = 0
294
+ const trackTransition = operation => {
295
+ pendingTransitions += 1
296
+ const tracked = Promise.resolve(operation)
297
+ transitionTail = Promise.allSettled([tracked])
298
+ const settle = () => { pendingTransitions -= 1 }
299
+ tracked.then(settle, settle)
300
+ return tracked
301
+ }
302
+ const enqueueTransition = task => {
303
+ const operation = transitionTail.then(task, task)
304
+ return trackTransition(operation)
305
+ }
306
+ const deferred = () => {
307
+ let resolve
308
+ let reject
309
+ const promise = new Promise((onResolve, onReject) => {
310
+ resolve = onResolve
311
+ reject = onReject
312
+ })
313
+ return { promise, resolve, reject }
314
+ }
315
+ const disposeRuntime = record => {
316
+ if (record?.disposal !== undefined) return record.disposal
317
+ record.disposal = record.runtime.dispose()
318
+ return record.disposal
319
+ }
320
+ const activationError = (error, cleanupError) => new AggregateError(
321
+ [error, cleanupError],
322
+ 'ptc-plus: runtime activation and cleanup failed',
323
+ { cause: error },
324
+ )
325
+ const cancelActivation = record => {
326
+ if (record === undefined) return Promise.resolve()
327
+ record.cancelled = true
328
+ if (activating === record) activating = undefined
329
+ const cleanup = record.runtime === undefined ? Promise.resolve() : disposeRuntime(record)
330
+ if (record.runtime !== undefined && record.trackedDisposal === undefined) {
331
+ record.trackedDisposal = trackTransition(cleanup)
332
+ }
333
+ cleanup.then(
334
+ () => record.completion.resolve({ status: 'superseded' }),
335
+ error => record.completion.reject(error),
336
+ )
337
+ return cleanup
338
+ }
339
+ const startActivation = record => {
340
+ if (record.cancelled || disposed || activating !== record) {
341
+ record.completion.resolve({ status: 'superseded' })
342
+ return
343
+ }
344
+ if (ctx.codeRuntime.language !== 'typescript') {
345
+ throw new Error('ptc-plus: unsupported code runtime language ' + JSON.stringify(ctx.codeRuntime.language) + '; only "typescript" is supported')
346
+ }
347
+ let candidate
348
+ try {
349
+ candidate = installPtCRuntime(ctx, record.config, toolSchemasForAgent, sessionId)
350
+ record.runtime = candidate
351
+ } catch (error) {
352
+ const cleanup = error?.[INSTALL_CLEANUP]
353
+ if (cleanup === undefined) throw error
354
+ const trackedCleanup = trackTransition(cleanup)
355
+ void trackedCleanup.then(
356
+ () => record.completion.reject(error),
357
+ cleanupError => record.completion.reject(activationError(error, cleanupError)),
358
+ )
359
+ if (activating === record) activating = undefined
360
+ return
361
+ }
362
+ const promote = () => {
363
+ if (record.cancelled || disposed || activating !== record) return
364
+ activating = undefined
365
+ committed = record
366
+ record.completion.resolve({ status: 'applied' })
367
+ }
368
+ if (candidate.ready === undefined) {
369
+ promote()
370
+ return
371
+ }
372
+ void Promise.resolve(candidate.ready).then(promote, async error => {
373
+ if (record.cancelled) return
374
+ const cleanup = disposeRuntime(record)
375
+ const trackedCleanup = record.trackedDisposal === undefined
376
+ ? (record.trackedDisposal = trackTransition(cleanup))
377
+ : record.trackedDisposal
378
+ if (activating === record) activating = undefined
379
+ try {
380
+ await trackedCleanup
381
+ } catch (cleanupError) {
382
+ record.completion.reject(activationError(error, cleanupError))
383
+ return
384
+ }
385
+ record.completion.reject(error)
386
+ })
387
+ }
388
+ const beginActivation = (nextConfig, generation, prerequisite) => {
389
+ const completion = deferred()
390
+ const record = {
391
+ config: nextConfig,
392
+ generation,
393
+ runtime: undefined,
394
+ disposal: undefined,
395
+ trackedDisposal: undefined,
396
+ cancelled: false,
397
+ completion,
398
+ }
399
+ activating = record
400
+ const start = () => startActivation(record)
401
+ if (prerequisite !== undefined || pendingTransitions > 0) {
402
+ void enqueueTransition(async () => {
403
+ if (prerequisite !== undefined) await prerequisite
404
+ start()
405
+ }).catch(error => {
406
+ if (activating === record) activating = undefined
407
+ completion.reject(error)
408
+ })
409
+ } else {
410
+ try {
411
+ start()
412
+ } catch (error) {
413
+ if (activating === record) activating = undefined
414
+ throw error
415
+ }
416
+ }
417
+ return completion.promise
418
+ }
419
+ const controller = {
420
+ apply(nextConfig, generation) {
421
+ if (disposed) return Promise.resolve({ status: 'superseded' })
422
+ if (activating !== undefined) {
423
+ const cleanup = cancelActivation(activating)
424
+ return beginActivation(nextConfig, generation, cleanup)
425
+ }
426
+ if (committed === undefined) return beginActivation(nextConfig, generation)
427
+ const current = committed
428
+ if (!nextConfig.cordisToolsEnabled) current.runtime.cancelCordisActivation()
429
+ return enqueueTransition(async () => {
430
+ if (disposed || committed !== current) return { status: 'superseded' }
431
+ await current.runtime.reconfigure(nextConfig)
432
+ current.config = nextConfig
433
+ current.generation = generation
434
+ return { status: 'applied' }
435
+ })
436
+ },
437
+ uninstall() {
438
+ const activationCleanup = activating === undefined
439
+ ? Promise.resolve()
440
+ : cancelActivation(activating)
441
+ const current = committed
442
+ committed = undefined
443
+ const committedCleanup = current === undefined
444
+ ? Promise.resolve()
445
+ : trackTransition(disposeRuntime(current))
446
+ return Promise.all([activationCleanup, committedCleanup]).then(() => undefined)
447
+ },
448
+ committedConfig() {
449
+ return committed?.config
450
+ },
451
+ async dispose() {
452
+ disposed = true
453
+ await controller.uninstall()
454
+ },
455
+ }
456
+ ctx.effect(() => async () => controller.dispose(), 'ptc-plus runtime lifecycle')
457
+
458
+ let configSource = () => resolvedConfig
459
+ let configurationGeneration = 0
460
+ let settingsWriter
461
+ let configurationRollback = false
462
+ const reportActivationFailure = (error) => {
463
+ ctx.logger?.warn?.('ptc-plus: runtime activation failed', error)
464
+ }
465
+ const handleConfigurationFailure = async (error, generation) => {
466
+ reportActivationFailure(error)
467
+ if (generation !== configurationGeneration || configurationRollback) return
468
+ configurationRollback = true
469
+ try {
470
+ if (settingsWriter?.update === undefined) return
471
+ const previousConfig = controller.committedConfig()
472
+ const patch = previousConfig === undefined
473
+ ? { enabled: false }
474
+ : Object.fromEntries(CONFIG_FIELDS.map(field => [field.key, previousConfig[field.key]]))
475
+ try {
476
+ await settingsWriter.update(settingsNamespace(SETTINGS_NAMESPACE), patch)
477
+ } catch (rollbackError) {
478
+ reportActivationFailure(new Error(
479
+ `ptc-plus: failed to roll back runtime configuration: ${rollbackError.message}`,
480
+ { cause: error },
481
+ ))
482
+ }
483
+ } finally {
484
+ configurationRollback = false
485
+ }
486
+ }
487
+ const reconcile = (propagateFailure = false) => {
488
+ const current = resolveConfig(configSource())
489
+ const generation = ++configurationGeneration
490
+ if (!current.enabled) {
491
+ const operation = controller.uninstall()
492
+ if (propagateFailure) return operation
493
+ void operation.catch(error => reportActivationFailure(new Error(
494
+ `ptc-plus: runtime disable failed: ${error.message}`,
495
+ { cause: error },
496
+ )))
497
+ return
498
+ }
499
+ if (configurationRollback) return
500
+ try {
501
+ const operation = Promise.resolve(controller.apply(current, generation))
502
+ if (propagateFailure) {
503
+ return operation.catch(error => {
504
+ reportActivationFailure(error)
505
+ throw error
506
+ })
507
+ }
508
+ void operation.catch(error => handleConfigurationFailure(error, generation))
509
+ } catch (error) {
510
+ if (settingsWriter === undefined) throw error
511
+ void handleConfigurationFailure(error, generation)
512
+ }
513
+ }
514
+ if (typeof ctx.inject === 'function') {
515
+ ctx.inject(['settings'], settings => {
516
+ settingsWriter = settings.settings
517
+ })
518
+ installSettingsSection(
519
+ ctx,
520
+ settingsNamespace(SETTINGS_NAMESPACE),
521
+ Config,
522
+ resolvedConfig,
523
+ {
524
+ setSource(source) {
525
+ configSource = source
526
+ },
527
+ onChange: reconcile,
528
+ },
529
+ )
530
+ }
531
+ if (configurationGeneration === 0) return reconcile(settingsWriter === undefined)
532
+ }
@@ -0,0 +1,20 @@
1
+ const AST_METADATA_KEYS = new Set(['start', 'end', 'loc', 'range'])
2
+
3
+ export const SKIP_AST_CHILDREN = Symbol('skip AST children')
4
+
5
+ /** Walk ESTree-shaped values while ignoring source-location metadata. */
6
+ export function walkAst(node, enter, leave = undefined, context = undefined, parent = undefined, parentKey = undefined) {
7
+ if (node === null || typeof node !== 'object') return
8
+ const entered = enter(node, parent, parentKey, context)
9
+ if (entered === SKIP_AST_CHILDREN) return
10
+ const childContext = entered === undefined ? context : entered
11
+ for (const [key, value] of Object.entries(node)) {
12
+ if (AST_METADATA_KEYS.has(key)) continue
13
+ if (Array.isArray(value)) {
14
+ for (const child of value) walkAst(child, enter, leave, childContext, node, key)
15
+ } else {
16
+ walkAst(value, enter, leave, childContext, node, key)
17
+ }
18
+ }
19
+ leave?.(node, parent, parentKey, childContext)
20
+ }
@@ -0,0 +1,95 @@
1
+ import { record } from './record-utils.js'
2
+
3
+ const SOFT_PLUGIN_NAMESPACES = new Set(['capabilities', 'code', 'repl'])
4
+
5
+ function name(value, label) {
6
+ if (typeof value !== 'string' || value.length === 0) {
7
+ throw new TypeError(`ptc-plus: ${label} must be a non-empty string`)
8
+ }
9
+ return value
10
+ }
11
+
12
+ function normalizeErrorClass(value, globalName) {
13
+ if (value === undefined) return undefined
14
+ const descriptor = record(value, `binding ${globalName} errorClass`)
15
+ return Object.freeze({
16
+ name: name(descriptor.name, `binding ${globalName} errorClass.name`),
17
+ memberNameProperty: name(
18
+ descriptor.memberNameProperty,
19
+ `binding ${globalName} errorClass.memberNameProperty`,
20
+ ),
21
+ })
22
+ }
23
+
24
+ function normalizeEmptyObjectMembers(value, globalName, members) {
25
+ if (value === undefined) return Object.freeze([])
26
+ if (globalName !== 'tools' || !Array.isArray(value)) {
27
+ throw new TypeError(`ptc-plus: binding ${globalName} emptyObjectMembers must be an array for tools`)
28
+ }
29
+ const available = new Set(members)
30
+ const seen = new Set()
31
+ const normalized = value.map((member) => {
32
+ const memberName = name(member, `binding ${globalName} emptyObjectMembers entry`)
33
+ if (!available.has(memberName)) {
34
+ throw new TypeError(`ptc-plus: binding ${globalName} emptyObjectMembers contains unknown member ${JSON.stringify(memberName)}`)
35
+ }
36
+ if (seen.has(memberName)) {
37
+ throw new TypeError(`ptc-plus: binding ${globalName} emptyObjectMembers contains duplicate member ${JSON.stringify(memberName)}`)
38
+ }
39
+ seen.add(memberName)
40
+ return memberName
41
+ })
42
+ return Object.freeze(normalized)
43
+ }
44
+
45
+ export function normalizeBindingDescriptors(bindings) {
46
+ if (!Array.isArray(bindings)) throw new TypeError('ptc-plus: bindings must be an array')
47
+ const reservedNames = new Set()
48
+ const namespaceNames = new Set()
49
+ const namespaces = bindings.map((value) => {
50
+ const namespace = record(value, 'binding namespace')
51
+ const globalName = name(namespace.global, 'binding namespace global')
52
+ if (namespaceNames.has(globalName)) {
53
+ throw new TypeError(`ptc-plus: duplicate binding namespace global ${JSON.stringify(globalName)}`)
54
+ }
55
+ namespaceNames.add(globalName)
56
+ const functions = record(namespace.functions, `binding ${globalName} functions`)
57
+ const members = Reflect.ownKeys(functions).map((member) => {
58
+ if (typeof member !== 'string' || member.length === 0) {
59
+ throw new TypeError(`ptc-plus: binding ${globalName} member names must be non-empty strings`)
60
+ }
61
+ const descriptor = Object.getOwnPropertyDescriptor(functions, member)
62
+ if (descriptor === undefined || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'function') {
63
+ throw new TypeError(`ptc-plus: binding ${globalName}.${member} is not an own callable value`)
64
+ }
65
+ return member
66
+ })
67
+ const errorClass = normalizeErrorClass(namespace.errorClass, globalName)
68
+ const emptyObjectMembers = normalizeEmptyObjectMembers(
69
+ namespace.emptyObjectMembers,
70
+ globalName,
71
+ members,
72
+ )
73
+ if (!SOFT_PLUGIN_NAMESPACES.has(globalName)) reservedNames.add(globalName)
74
+ if (errorClass !== undefined) reservedNames.add(errorClass.name)
75
+ return Object.freeze({
76
+ global: globalName,
77
+ functions,
78
+ members: Object.freeze(members),
79
+ ...(emptyObjectMembers.length === 0 ? {} : { emptyObjectMembers }),
80
+ ...(errorClass === undefined ? {} : { errorClass }),
81
+ })
82
+ })
83
+ return Object.freeze({
84
+ namespaces: Object.freeze(namespaces),
85
+ reservedNames,
86
+ workerDescriptors: Object.freeze(namespaces.map(namespace => Object.freeze({
87
+ global: namespace.global,
88
+ members: namespace.members,
89
+ ...(namespace.emptyObjectMembers === undefined
90
+ ? {}
91
+ : { emptyObjectMembers: namespace.emptyObjectMembers }),
92
+ ...(namespace.errorClass === undefined ? {} : { errorClass: namespace.errorClass }),
93
+ }))),
94
+ })
95
+ }