dsh-ptc-plus 0.5.0 → 0.5.2

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 (74) hide show
  1. package/README.en.md +15 -3
  2. package/README.md +15 -3
  3. package/client.cjs +1983 -234
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +2 -2
  8. package/docs/adr/0023-global-user-bindings.md +1 -1
  9. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  10. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  11. package/docs/adr/0038-freeze-session-capability-plans.md +1 -1
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +35 -0
  13. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  14. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  15. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  16. package/docs/adr/README.md +2 -0
  17. package/docs/architecture.md +2 -2
  18. package/docs/capability-planning-components.md +2 -2
  19. package/docs/client-ui.md +13 -10
  20. package/docs/client-visual-contract.md +1 -1
  21. package/docs/durability-design.md +7 -1
  22. package/docs/installation.md +5 -3
  23. package/docs/publishing.md +2 -2
  24. package/docs/releases/v0.5.2.md +79 -0
  25. package/docs/runtime-reference.md +42 -3
  26. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  27. package/docs/verification.md +6 -2
  28. package/index.js +63 -19
  29. package/internal/bounded-literal.js +85 -0
  30. package/internal/capability-command-receipt.js +3 -1
  31. package/internal/capability-environment.js +171 -13
  32. package/internal/capability-plan.js +11 -8
  33. package/internal/capability-statistics.js +6 -2
  34. package/internal/cell-error.js +13 -0
  35. package/internal/config-spec.js +122 -2
  36. package/internal/direct-surface-owner.js +14 -10
  37. package/internal/edit-transport-owner.js +96 -17
  38. package/internal/execution-provider-owner.js +25 -0
  39. package/internal/execution-seam-compat.js +18 -0
  40. package/internal/isolated-worker.js +134 -17
  41. package/internal/kernel-worker.js +167 -3
  42. package/internal/legacy-cell-analysis.js +14 -2
  43. package/internal/legacy-repl-convenience.js +16 -3
  44. package/internal/official-repl-priority.js +51 -0
  45. package/internal/projection-retention.js +238 -0
  46. package/internal/prompt-text.js +858 -0
  47. package/internal/recovery-tips.js +50 -11
  48. package/internal/rejected-cell-editor.js +3 -2
  49. package/internal/runtime-bridge-owner.js +11 -8
  50. package/internal/runtime-config.js +2 -0
  51. package/internal/runtime-contexts.js +14 -5
  52. package/internal/runtime-messages.js +43 -15
  53. package/internal/sdk-projection.js +2 -2
  54. package/internal/session-cell-executor.js +132 -11
  55. package/internal/session-journal-recovery.js +1009 -99
  56. package/internal/session-journal-schema.js +7 -5
  57. package/internal/session-journal.js +177 -14
  58. package/internal/session-log-view.js +111 -24
  59. package/internal/session-runtime.js +674 -33
  60. package/internal/session-state.js +27 -0
  61. package/internal/stateful-root-compiler.js +11 -0
  62. package/internal/stateful-root-runtime.js +17 -0
  63. package/internal/user-binding-console.js +7 -0
  64. package/internal/user-bindings-owner.js +8 -27
  65. package/internal/user-bindings.js +2 -2
  66. package/internal/validated-parse-repair.js +6 -1
  67. package/internal/worker-client.js +27 -4
  68. package/package.json +5 -2
  69. package/src/client-activity.js +27 -6
  70. package/src/client-capabilities-view.js +774 -125
  71. package/src/client-copy.js +231 -18
  72. package/src/client-repl-view.js +6 -1
  73. package/src/client-styles.js +68 -2
  74. package/src/client-tool-view.js +9 -2
@@ -94,6 +94,33 @@ export class BindingCatalog {
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * Catalog after installing recorded values.
99
+ *
100
+ * A restored node is not compiled: its source may be dead (it can reference a
101
+ * binding a recorded rewind ended), so there is no preparation to take
102
+ * declarations from. The journal records the names and values but not their
103
+ * declaration kind, so a restored binding is catalogued as reassignable. That is
104
+ * deliberately permissive: refusing a later `let`-style redeclaration would break
105
+ * continuation, while accepting one only widens what the session may do with a
106
+ * binding whose original declaration the log cannot describe.
107
+ */
108
+ restore(names) {
109
+ if (!Array.isArray(names) || names.length === 0) return this
110
+ const entries = new Map(this.#entries)
111
+ for (const name of names) {
112
+ if (typeof name !== 'string' || entries.has(name)) continue
113
+ entries.set(name, { kind: 'let', writable: true })
114
+ }
115
+ return new BindingCatalog({
116
+ entries,
117
+ namespaces: this.#namespaces,
118
+ userBindingAncestry: this.#userBindingAncestry,
119
+ rootCandidates: this.#rootCandidates,
120
+ dynamicOrigins: this.#dynamicOrigins,
121
+ })
122
+ }
123
+
97
124
  advance(prepared, source = undefined, committedRedeclarations = undefined, rootBindingFacts = undefined) {
98
125
  const semantics = normalizeLanguageSemantics(prepared.languageSemantics ?? LEGACY_LANGUAGE_SEMANTICS)
99
126
  if (semantics === LEGACY_LANGUAGE_SEMANTICS) {
@@ -1,4 +1,5 @@
1
1
  /** Compile each cell into a private native frame backed by session identities. */
2
+ import { boundedLiteralEvidence } from './bounded-literal.js'
2
3
  import { adaptRootModuleImports } from './managed-module-operations.js'
3
4
  import { parse } from '@babel/parser'
4
5
  import { createHash } from 'node:crypto'
@@ -321,11 +322,21 @@ export function compileStatefulRoot(program, {
321
322
  return remember(text, [{ generatedStart: 6, generatedEnd: 6 + source.length,
322
323
  originalStart: original.start, originalEnd: original.end }])
323
324
  }
325
+ const firstDeclaration = declarations.length
324
326
  const target = record(names[0], 'variable', node, bindings[0], `:${declarator.start}`)
325
327
  for (const binding of bindings.slice(1)) {
326
328
  record(binding.name, 'variable', node, binding, `:${declarator.start}`)
327
329
  declarations.at(-1).commitDependency = target
328
330
  }
331
+ // A single `const name = { … }` initializer proves its own size from the
332
+ // source, so settlement may record that value without walking an object the
333
+ // codec cannot size. A destructuring pattern has no one initializer to prove.
334
+ if (declarator.id.type === 'Identifier' && supplied === undefined && declarator.init !== null) {
335
+ const evidence = boundedLiteralEvidence(declarator.init)
336
+ if (evidence !== undefined) {
337
+ for (const declaration of declarations.slice(firstDeclaration)) declaration.boundedLiteral = evidence
338
+ }
339
+ }
329
340
  if (declarator.init === null && supplied === undefined) {
330
341
  return emptyCompletion(`${runtime}.declare(${JSON.stringify(names[0])}, ${JSON.stringify(target)})`)
331
342
  }
@@ -333,6 +333,23 @@ export function createStatefulRootRuntime({ readAmbient, typeofAmbient, writeAmb
333
333
  const entry = mapGet(entries, name)
334
334
  return entry?.source === 'local' && entry.object !== true && entry.read === undefined ? { value: entry.value } : undefined
335
335
  },
336
+ /**
337
+ * Install a recorded binding without running the cell that produced it.
338
+ *
339
+ * A volatile cell cannot be replayed: re-executing its source would
340
+ * redispatch the ambient effects that made it volatile. Recovery therefore
341
+ * installs the value the journal recorded, as the committed local
342
+ * declaration the original cell's own commit produced. Returns false when
343
+ * the store cannot publish a session identity for the name, so the caller
344
+ * reports that binding instead of claiming it was restored.
345
+ */
346
+ restoreValue(name, value) {
347
+ const entry = storeValue(name, value, { declaration: true })
348
+ if (entry === undefined) return false
349
+ publish(name, () => read(name), next => write(name, next))
350
+ changed(name, 'local')
351
+ return true
352
+ },
336
353
  has: name => mapHas(entries, name) && mapGet(entries, name).object !== true && mapGet(entries, name).deleted !== true,
337
354
  facts() {
338
355
  const facts = []
@@ -55,6 +55,12 @@ export class UserBindingConsole {
55
55
  async reconfigure(options) {
56
56
  // Reconfiguration releases the current environments; the owner stays usable so
57
57
  // a later run can start again.
58
+ if (this.options.workerReclaimMs !== options.workerReclaimMs && Number.isSafeInteger(options.workerReclaimMs)) {
59
+ // A bound change needs no new worker: the close path reads the transport.
60
+ for (const environment of this.environments.values()) {
61
+ environment.worker.reclaimBoundMs = options.workerReclaimMs
62
+ }
63
+ }
58
64
  if (['maxWallMs', 'maxOutputBytes', 'maxOldGenerationSizeMb'].some(key => this.options[key] !== options[key])) {
59
65
  for (const capability of [...this.environments.keys()]) this.release(capability, 'reconfigured')
60
66
  const failures = await this.owner.releaseAll()
@@ -88,6 +94,7 @@ export class UserBindingConsole {
88
94
  workerData: { source, cwd: this.options.cwd, maxOutputBytes: this.options.maxOutputBytes },
89
95
  env: normalizeWorkerEnvironment(process.env),
90
96
  resourceLimits: { maxOldGenerationSizeMb: this.options.maxOldGenerationSizeMb },
97
+ reclaimBoundMs: this.options.workerReclaimMs,
91
98
  protocol: 'parent-port',
92
99
  })
93
100
  const worker = started.transport
@@ -1,5 +1,5 @@
1
1
  import { PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
2
- import { bindingToggleMessage, readBindingToggle } from './binding-toggle-receipt.js'
2
+ import { bindingToggleMessage } from './binding-toggle-receipt.js'
3
3
  import { RPC_CONTRACTS } from './rpc-contract.js'
4
4
  import { randomUUID } from 'node:crypto'
5
5
  import { dirname } from 'node:path'
@@ -111,6 +111,7 @@ function runCandidate(source, invocation, options, signal, owner) {
111
111
  workerData: { source, invocation, valueLimits: options.valueLimits, cwd: options.cwd },
112
112
  env: normalizeWorkerEnvironment(process.env),
113
113
  resourceLimits: { maxOldGenerationSizeMb: options.maxOldGenerationSizeMb },
114
+ reclaimBoundMs: options.workerReclaimMs,
114
115
  })
115
116
  const worker = started.transport
116
117
  const outputLimitError = () => Object.assign(
@@ -183,10 +184,10 @@ export function createUserBindingsOwner(ctx, options = {}) {
183
184
  maxWallMs: options.maxWallMs,
184
185
  maxOutputBytes: options.maxOutputBytes,
185
186
  maxOldGenerationSizeMb: options.maxOldGenerationSizeMb,
187
+ workerReclaimMs: options.workerReclaimMs,
186
188
  valueLimits: options.valueLimits,
187
189
  }
188
190
  let disposed = false
189
- const pendingConfigurationReceipts = new WeakMap()
190
191
  const codeConsole = new UserBindingConsole(currentOptions)
191
192
  const commandRegistrations = new Map()
192
193
  const sessionAgents = new Map()
@@ -387,15 +388,11 @@ export function createUserBindingsOwner(ctx, options = {}) {
387
388
  const sessions = new Set((ctx.agents?.list?.() ?? []).map(agent => agent.session).filter(Boolean))
388
389
  for (const session of sessions) {
389
390
  try {
391
+ // Before the first accepted step these are initial settings, not changes
392
+ // to an environment the model has received. The catalog owns first delivery.
393
+ if (session.deriveMessages()[0]?.role !== 'system') continue
390
394
  const message = createUserMessage(bindingToggleMessage(entry, next.revision))
391
- // The Host alone establishes the first system head. Before that accepted
392
- // boundary, keep UI configuration facts pending without waking the Agent.
393
- if (session.deriveMessages()[0]?.role === 'system') session.append('user/message', message, { surfaceOp: 'append' })
394
- else {
395
- const pending = pendingConfigurationReceipts.get(session) ?? []
396
- pending.push(message)
397
- pendingConfigurationReceipts.set(session, pending)
398
- }
395
+ session.append('user/message', message, { surfaceOp: 'append' })
399
396
  } catch (error) { reportContainedFailure('ptc-plus: binding configuration saved but its session receipt could not be appended', error) }
400
397
  }
401
398
  return next
@@ -916,23 +913,6 @@ export function createUserBindingsOwner(ctx, options = {}) {
916
913
  mountAuthoring()
917
914
 
918
915
  return Object.freeze({
919
- committedEvent(session, event) {
920
- if (event.type !== 'user/message' || !readBindingToggle(event.data)) return
921
- const pending = pendingConfigurationReceipts.get(session)
922
- if (pending) pendingConfigurationReceipts.set(session, pending.filter(message => message.id !== event.data.id))
923
- },
924
- async preStep(payload, next) {
925
- const decision = await next()
926
- if (disposed || decision?.kind !== 'enter' || payload.signal?.aborted || payload.agent?.signal?.aborted) return decision
927
- const session = payload.agent?.session
928
- const pending = pendingConfigurationReceipts.get(session)
929
- if (!pending?.length) return decision
930
- const visible = new Set(session.deriveMessages().map(message => message.id))
931
- const remaining = pending.filter(message => !visible.has(message.id))
932
- pendingConfigurationReceipts.set(session, remaining)
933
- const proposed = new Set((decision.messages ?? []).map(message => message.id))
934
- return { ...decision, messages: [...(decision.messages ?? []), ...remaining.filter(message => !proposed.has(message.id))] }
935
- },
936
916
  path: store.filename,
937
917
  cwd: bindingsCwd,
938
918
  async snapshot() {
@@ -997,6 +977,7 @@ export function createUserBindingsOwner(ctx, options = {}) {
997
977
  maxWallMs: nextConfig.maxWallMs,
998
978
  maxOutputBytes: nextConfig.maxOutputBytes,
999
979
  maxOldGenerationSizeMb: nextConfig.maxOldGenerationSizeMb,
980
+ workerReclaimMs: nextConfig.workerReclaimMs,
1000
981
  valueLimits: valueLimitsFromConfig(nextConfig),
1001
982
  }
1002
983
  if (nextEnabled === previousEnabled) {
@@ -268,7 +268,7 @@ export function userBindingsDeclaration(snapshot) {
268
268
  return normalized.entries.map(entry => entry.declaration).join('\n\n')
269
269
  }
270
270
 
271
- export function userBindingsConfiguredContext(snapshot) {
271
+ export function userBindingsConfiguredContext(snapshot, { entryLabel = 'Binding' } = {}) {
272
272
  const entries = normalizeUserBindingsSnapshot(snapshot).entries
273
273
  .filter(entry => {
274
274
  const { includeDeclaration, instructions } = bindingModelPreferences(entry.modelContext)
@@ -279,7 +279,7 @@ export function userBindingsConfiguredContext(snapshot) {
279
279
  const content = entries.map(entry => {
280
280
  const { includeDeclaration, instructions } = bindingModelPreferences(entry.modelContext)
281
281
  return [
282
- `Binding: ${entry.name}`,
282
+ `${entryLabel}: ${entry.name}`,
283
283
  instructions,
284
284
  includeDeclaration ? `\`\`\`ts\n${entry.declaration}\n\`\`\`` : '',
285
285
  ].filter(Boolean).join('\n\n')
@@ -31,7 +31,7 @@ function startsInsideSurrogatePair(source, index) {
31
31
  return current >= 0xdc00 && current <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff
32
32
  }
33
33
 
34
- function boundedUniqueSuffix(source) {
34
+ export function boundedUniqueSuffix(source) {
35
35
  const first = Math.max(0, source.length - MAX_PARSE_REPAIR_ANCHOR_CODE_UNITS)
36
36
  for (let start = source.length - 1; start >= first; start -= 1) {
37
37
  if (startsInsideSurrogatePair(source, start)) continue
@@ -81,6 +81,11 @@ export function validatedEofClosureRepair({ source, position, prepare, targetCal
81
81
  const candidate = candidates[0]
82
82
  return Object.freeze({
83
83
  ...candidate,
84
+ // The diagnostic names the target cell instead of embedding the whole
85
+ // invocation: the JSON form is hundreds of characters the model has to copy
86
+ // before it can act, while the delimiter and the target are the two facts it
87
+ // needs.
88
+ targetCallSeq,
84
89
  invocation: renderInvocation(candidate.arguments),
85
90
  })
86
91
  }
@@ -1,4 +1,4 @@
1
- import { mkdtemp, rm } from 'node:fs/promises'
1
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
2
2
  import { randomBytes } from 'node:crypto'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { isAbsolute, join } from 'node:path'
@@ -46,7 +46,15 @@ export class WorkerClient {
46
46
  return lastLine === undefined ? undefined : lastLine.slice(0, 300)
47
47
  }
48
48
 
49
- async ensure(maxOldGenerationSizeMb) {
49
+ /**
50
+ * Start the session worker once and reuse it.
51
+ *
52
+ * `bounds` carries the startup bound for a worker that does not exist yet and
53
+ * the close bound this worker starts with; a later close reads the bound the
54
+ * owning kernel sets from its current configuration. It may be a getter, which
55
+ * is read after the scratch wait because a settings change can land there.
56
+ */
57
+ async ensure(maxOldGenerationSizeMb, bounds = {}) {
50
58
  if (this.pendingOutputFailure !== undefined) {
51
59
  const message = this.pendingOutputFailure
52
60
  this.pendingOutputFailure = undefined
@@ -61,7 +69,14 @@ export class WorkerClient {
61
69
  if (!isAbsolute(scratchRoot)) {
62
70
  throw new Error(`ptc-plus: host temporary directory must be absolute, got ${JSON.stringify(scratchRoot)}`)
63
71
  }
64
- this.scratchReady = mkdtemp(join(scratchRoot, 'dsh-ptc-plus-'))
72
+ this.scratchReady = mkdtemp(join(scratchRoot, 'dsh-ptc-plus-')).catch(async error => {
73
+ // A host that points TMPDIR at a directory it has not created yet (macOS CI)
74
+ // fails mkdtemp with ENOENT; create the root once and retry instead of
75
+ // failing the cell with a worker-exit error.
76
+ if (error?.code !== 'ENOENT') throw error
77
+ await mkdir(scratchRoot, { recursive: true })
78
+ return mkdtemp(join(scratchRoot, 'dsh-ptc-plus-'))
79
+ })
65
80
  }
66
81
  this.workerLimit = maxOldGenerationSizeMb
67
82
  let scratchDirectory
@@ -79,13 +94,21 @@ export class WorkerClient {
79
94
  /* c8 ignore next */
80
95
  if (this.worker !== undefined) return this.workerReady
81
96
  const environment = normalizeWorkerEnvironment(process.env)
97
+ // A settings change can land while the scratch directory is being created,
98
+ // so the bounds are read here rather than at the call.
99
+ const resolvedBounds = typeof bounds === 'function' ? bounds() : bounds
82
100
  let worker
83
101
  try {
84
102
  const started = this.owner.start({
85
103
  helper: KERNEL_HELPER,
86
104
  entry: this.workerUrl,
87
- workerData: { cwd: this.cwd, compilerCache: this.compilerCache?.() },
105
+ workerData: {
106
+ cwd: this.cwd,
107
+ compilerCache: this.compilerCache?.(),
108
+ startupProbeMs: resolvedBounds.startupProbeMs,
109
+ },
88
110
  resourceLimits: { maxOldGenerationSizeMb: this.workerLimit },
111
+ reclaimBoundMs: resolvedBounds.reclaimBoundMs,
89
112
  env: {
90
113
  ...environment,
91
114
  TEMP: scratchDirectory,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ptc-plus",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "icon": "assets/ptc-plus-icon.svg",
5
5
  "description": "A session-bound agent-native REPL for DeepSeek Harness PTC mode.",
6
6
  "type": "module",
@@ -42,7 +42,8 @@
42
42
  "LICENSE"
43
43
  ],
44
44
  "engines": {
45
- "node": "^22.19.0 || >=24.0.0"
45
+ "node": "^22.19.0 || >=24.0.0",
46
+ "dsh": "*"
46
47
  },
47
48
  "allowScripts": {
48
49
  "esbuild": false
@@ -106,8 +107,10 @@
106
107
  "coverage:focus": "node scripts/coverage.mjs --focus",
107
108
  "test:backend:focus": "node scripts/test-suite-files.mjs --focus",
108
109
  "test:backend": "node scripts/test-suite-files.mjs",
110
+ "test:backend:fast": "node scripts/test-suite-files.mjs --fast",
109
111
  "test:ci:runtime": "node --test --test-concurrency=1 test/package-contract.test.js test/compiler-platform.test.js test/direct-surface-policy.test.js test/isolated-entrance.test.js test/plugin-native-call-transport.test.js",
110
112
  "verify": "npm run review:validate && npm run host:baseline:verify && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node --check scripts/migrate-session-log.mjs && node --check scripts/expensive-headless-acceptance.mjs && node --check scripts/ab-headless-trajectories.mjs && node scripts/deterministic-test-groups.mjs",
113
+ "verify:check": "npm run review:validate && npm run host:baseline:verify && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node scripts/deterministic-test-groups.mjs --check",
111
114
  "verify:ci": "npm run review:validate && npm run host:baseline:verify && npm run semantic:check && npm run build:check && node --check index.js && node --check internal/kernel-worker.js && node --check scripts/migrate-session-log.mjs && node scripts/deterministic-test-groups.mjs --ci",
112
115
  "check": "node scripts/review-findings.mjs check",
113
116
  "verify:docs": "node scripts/review-findings.mjs docs",
@@ -249,12 +249,30 @@ function isValidModuleSemantics(value, version) {
249
249
  && (legacy || IMPORT_EXPRESSION_BOUNDARIES.has(value.importExpressionBoundary))
250
250
  }
251
251
 
252
+ /**
253
+ * The retention window keeps everything recovery reads and replaces one payload
254
+ * with the digest of exactly the bytes it used to carry, so a recorded call keeps
255
+ * the Value V1 wire of its arguments or the digest of that wire, and a derived run
256
+ * keeps its source or the digest of that source. `normalizeCalls` and
257
+ * `normalizeDerivedRelation` accept exactly one of the two shapes and reject a
258
+ * record that claims both, so this boundary reports which shape it read.
259
+ */
260
+ function payloadShape(value, textField, digestField, bytesField) {
261
+ if (Object.hasOwn(value, textField)) {
262
+ return Object.hasOwn(value, digestField) || Object.hasOwn(value, bytesField) ? undefined : 'text'
263
+ }
264
+ return typeof value[digestField] === 'string' && /^[a-f0-9]{64}$/u.test(value[digestField])
265
+ && isSafeSequence(value[bytesField]) ? 'digest' : undefined
266
+ }
267
+
252
268
  function isValidCall(value) {
253
269
  if (!isRecord(value) || (value.ok !== true && value.ok !== false)) return false
254
270
  const fields = value.ok ? CALL_SUCCESS_FIELDS : CALL_ERROR_FIELDS
255
- if (!hasExactFields(value, fields) || typeof value.global !== 'string'
256
- || typeof value.member !== 'string' || !isValidValueWire(value.args)
271
+ const argsShape = payloadShape(value, 'args', 'argsDigest', 'argsBytes')
272
+ if (argsShape === undefined || !hasClosedFields(value, fields)
273
+ || typeof value.global !== 'string' || typeof value.member !== 'string'
257
274
  || !isSafeSequence(value.settle)) return false
275
+ if (argsShape === 'text' && !isValidValueWire(value.args)) return false
258
276
  return value.ok ? isValidValueWire(value.value) : typeof value.error === 'string'
259
277
  }
260
278
 
@@ -389,8 +407,10 @@ function isValidEditTarget(value) {
389
407
 
390
408
  function isValidDerivedRun(value) {
391
409
  try {
392
- return hasExactFields(value, DERIVED_RUN_FIELDS)
393
- && typeof value.code === 'string' && typeof value.description === 'string'
410
+ if (!isRecord(value) || !hasClosedFields(value, DERIVED_RUN_FIELDS)
411
+ || typeof value.description !== 'string') return false
412
+ const codeShape = payloadShape(value, 'code', 'codeDigest', 'codeBytes')
413
+ return codeShape === 'digest' || (codeShape === 'text' && typeof value.code === 'string')
394
414
  } catch {
395
415
  return false
396
416
  }
@@ -506,6 +526,7 @@ export function derivePtcToolView(block, toolName = undefined) {
506
526
  ? block.meta[GENERATED_RUN_CODE_DESCRIPTION_KEY]
507
527
  : undefined
508
528
  const description = (explicitDescription ?? generatedDescription)?.split(/\r?\n/, 1)[0] ?? ''
529
+ const descriptionGenerated = explicitDescription === undefined && generatedDescription !== undefined
509
530
  const output = settled ? resultText(block) : ''
510
531
  const state = !settled ? 'running'
511
532
  : block.error?.code === 'interrupted' ? 'stopped'
@@ -514,7 +535,7 @@ export function derivePtcToolView(block, toolName = undefined) {
514
535
  ? block.meta.dshPtcPlus
515
536
  : undefined
516
537
  if (journal === undefined) {
517
- return Object.freeze({ state, description, code, output, ptc: false, features: Object.freeze([]) })
538
+ return Object.freeze({ state, description, descriptionGenerated, code, output, ptc: false, features: Object.freeze([]) })
518
539
  }
519
540
  const rewrites = isValidRewrites(block.meta.dshPtcPlusRewrites, journal)
520
541
  ? block.meta.dshPtcPlusRewrites
@@ -542,5 +563,5 @@ export function derivePtcToolView(block, toolName = undefined) {
542
563
  : undefined,
543
564
  ...operationFeatures(journal.operations),
544
565
  ])
545
- return Object.freeze({ state, description, code, output, ptc: true, features: Object.freeze(features) })
566
+ return Object.freeze({ state, description, descriptionGenerated, code, output, ptc: true, features: Object.freeze(features) })
546
567
  }