Bladeren bron

Merge pull request #537 from deepseek-harness/feat/telemetry-otel-plugin

feat(telemetry): session-telemetry seam with mandatory redaction + OTel backend
Tianyi Cui 1 maand geleden
bovenliggende
commit
c03c4fdbdd
51 gewijzigde bestanden met toevoegingen van 2506 en 12 verwijderingen
  1. 6 0
      .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
  2. 30 0
      .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
  3. 30 0
      .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
  4. 6 0
      docs/capability-seams.md
  5. 35 0
      docs/config-catalog.md
  6. 29 0
      docs/cordis-catalog/events.md
  7. 24 1
      docs/cordis-catalog/services.md
  8. 2 2
      docs/core-data-structures/session.i18n.yaml
  9. 12 0
      docs/core-data-structures/session.md
  10. 12 0
      docs/core-data-structures/session.zh.md
  11. 6 5
      docs/event-producer-consumer.md
  12. 13 0
      docs/module-graph.md
  13. 0 0
      examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
  14. 43 0
      examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts
  15. 28 0
      examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
  16. 29 0
      examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts
  17. 1 0
      examples/package.json
  18. 12 0
      knip.json
  19. 3 3
      packages/README.i18n.yaml
  20. 1 0
      packages/README.md
  21. 1 0
      packages/README.zh.md
  22. 34 1
      packages/cordis/tool-cordis/src/api-catalog.ts
  23. 14 0
      packages/core/session/src/index.ts
  24. 6 0
      packages/telemetry/README.i18n.yaml
  25. 10 0
      packages/telemetry/README.md
  26. 10 0
      packages/telemetry/README.zh.md
  27. 6 0
      packages/telemetry/session-telemetry-otel/README.i18n.yaml
  28. 41 0
      packages/telemetry/session-telemetry-otel/README.md
  29. 41 0
      packages/telemetry/session-telemetry-otel/README.zh.md
  30. 53 0
      packages/telemetry/session-telemetry-otel/package.json
  31. 181 0
      packages/telemetry/session-telemetry-otel/src/index.ts
  32. 32 0
      packages/telemetry/session-telemetry-otel/src/invariant.ts
  33. 99 0
      packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts
  34. 237 0
      packages/telemetry/session-telemetry-otel/tests/otel.spec.ts
  35. 33 0
      packages/telemetry/session-telemetry-otel/tsconfig.json
  36. 6 0
      packages/telemetry/session-telemetry/README.i18n.yaml
  37. 42 0
      packages/telemetry/session-telemetry/README.md
  38. 42 0
      packages/telemetry/session-telemetry/README.zh.md
  39. 41 0
      packages/telemetry/session-telemetry/package.json
  40. 280 0
      packages/telemetry/session-telemetry/src/coordinator.ts
  41. 156 0
      packages/telemetry/session-telemetry/src/index.ts
  42. 32 0
      packages/telemetry/session-telemetry/src/invariant.ts
  43. 116 0
      packages/telemetry/session-telemetry/tests/redact.spec.ts
  44. 424 0
      packages/telemetry/session-telemetry/tests/telemetry.spec.ts
  45. 27 0
      packages/telemetry/session-telemetry/tsconfig.json
  46. 203 0
      pnpm-lock.yaml
  47. 1 0
      scripts/gen-cordis-catalog.ts
  48. 10 0
      scripts/gen-doc-graphs.ts
  49. 2 0
      scripts/verify-package-readme-model-experience.ts
  50. 2 0
      tsconfig.base.json
  51. 2 0
      tsconfig.host.json

+ 6 - 0
.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
+2026-07-23-session-telemetry-otel-revival.md: a58598d8a956d47cb0cf6aa3e659f38314bc4b17
+2026-07-23-session-telemetry-otel-revival.zh.md: cc09717e349d5ae2ab5157bf46de30b1823c775f

File diff suppressed because it is too large
+ 30 - 0
.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md


File diff suppressed because it is too large
+ 30 - 0
.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md


+ 6 - 0
docs/capability-seams.md

@@ -35,6 +35,9 @@ flowchart LR
   pkg_tool_bash["tool-bash"]
   pkg_hooks_claude["hooks-claude"]
   pkg_hooks_codex["hooks-codex"]
+  pkg_session_telemetry["session-telemetry"]
+  svc_telemetry["ctx.telemetry<br/>Session telemetry seam"]
+  pkg_session_telemetry_otel["session-telemetry-otel"]
   pkg_storage["storage"]
   svc_storage["ctx.storage<br/>Non-session storage hub"]
   pkg_storage_json["storage-json"]
@@ -180,6 +183,8 @@ flowchart LR
   pkg_session_query --> svc_sessionQuery
   pkg_session_query_sqlite --> svc_sessionQuery
   pkg_session_reference --> svc_sessionReferences
+  pkg_session_telemetry --> svc_telemetry
+  pkg_session_telemetry_otel --> svc_telemetry
   pkg_session_title --> svc_sessionTitle
   pkg_session_title_all_messages_llm --> svc_sessionTitle
   pkg_session_title_first_message_llm --> svc_sessionTitle
@@ -311,6 +316,7 @@ flowchart LR
 | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
 | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
+| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
 | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |

+ 35 - 0
docs/config-catalog.md

@@ -1098,6 +1098,40 @@ export interface Config {
 
 Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts)
 
+## `@deepseek-ai/dsh-session-telemetry-otel`
+
+Requires: `sessions`
+
+```ts config-catalog
+/**
+ * Plugin configuration: two verbatim SDK option shapes plus nothing else.
+ * `exporter.url` is the one field this package validates itself — required,
+ * no default, must parse as an `http(s)` URL — because a missing endpoint
+ * must fail at plugin load, not at first export.
+ */
+export interface Config {
+  /**
+   * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
+   * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
+   * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
+   * is the one field this package requires and validates itself.
+   */
+  exporter?: OTLPExporterNodeConfigBase & {
+    /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */
+    url?: string
+  }
+  /**
+   * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
+   * which this plugin fills); the SDK owns and documents these knobs.
+   */
+  processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
+}
+```
+
+Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
+
+Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts)
+
 ## `@deepseek-ai/dsh-session-title`
 
 Requires: `sessions`
@@ -2123,6 +2157,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts))
 - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts))
+- `@deepseek-ai/dsh-session-telemetry` ([`packages/telemetry/session-telemetry/src/index.ts`](../packages/telemetry/session-telemetry/src/index.ts))
 - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts))
 - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
 - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts))

+ 29 - 0
docs/cordis-catalog/events.md

@@ -892,6 +892,35 @@ Emitted when any prompt provider changes. This registry notification is unfilter
 
 Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts)
 
+## `telemetry/*`
+
+### `telemetry/record` — waterfall
+
+Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten.
+
+```ts cordis-catalog
+/**
+ * Transform one outbound record before it reaches the backend. This
+ * waterfall is the seam's redaction extension point. It ships NO rules
+ * of its own: the
+ * innermost `next()` passes the record through unchanged, and with no
+ * listener mounted records reach the backend as captured, so exported
+ * data is exactly as clean as the rules a deployment mounts. Listeners
+ * stack by transforming `next()`'s return value; returning without
+ * `next()` replaces everything beneath. Dispatched synchronously on the
+ * capture hot path inside the coordinator's containment: a throwing
+ * listener withholds that one record (fail-closed) and never reaches the
+ * agent loop. Redaction applies to the exported copy only; the canonical
+ * session log is never rewritten.
+ * @param record - the candidate record, already the coordinator's own deep
+ *   copy; listeners return a (possibly new) record and must not mutate it.
+ * @mode waterfall
+ */
+'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
+```
+
+Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts)
+
 ## `tools/*`
 
 ### `tools/change` — emit

+ 24 - 1
docs/cordis-catalog/services.md

@@ -1366,7 +1366,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
 
 Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/index.ts:611`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:625`](../../packages/core/session/src/index.ts)
 
 ## `ctx.sessionTitle` — `SessionTitleService`
 
@@ -1751,6 +1751,29 @@ Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-da
 
 Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts)
 
+## `ctx.telemetry` — `Telemetry` (abstract seam)
+
+The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side.
+
+```ts cordis-catalog
+/**
+ * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home.
+ * @param record - the logical record to report; owned by the backend after the call.
+ */
+abstract emit(record: TelemetryRecord): void
+
+/** See {@link TelemetryBackend.flush}. */
+flush?(): void
+
+/**
+ * See {@link TelemetryBackend.shutdown}.
+ * @returns resolves when the backend's pipeline has quiesced.
+ */
+abstract shutdown(): Promise<void>
+```
+
+Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts)
+
 ## `ctx.tokenMeter` — `TokenMeterService`
 
 Replay owner for one service-wide estimator and isolated per-session folds.

+ 2 - 2
docs/core-data-structures/session.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
-session.md: 59bcf027c3ec728c5583478c7d17bd6eef2dc2fa
-session.zh.md: 01b6f080b5fcf7b83ca46b99a8d80113a6ebe8bf
+session.md: 52170c584d6a0734c499e52183f91d1b07c02862
+session.zh.md: 77f7ae1a48b14fc1ac3fb693c30701d167b2612e

+ 12 - 0
docs/core-data-structures/session.md

@@ -370,6 +370,18 @@ declare class Session {
   readonly header: SessionHeader;
   /** The session identity, derived from its durable header's single copy. */
   get id(): SessionId;
+  /**
+   * The first seq appended IN THIS PROCESS: the length of the constructor
+   * seed (0 without one). Events below it entered through construction —
+   * replay, fork, or resume — and were never published on the `session/event`
+   * firehose (constructor seeds do not emit), so consumers that replay the
+   * log as a publication substitute (telemetry adoption) start here. Distinct
+   * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
+   * session's constructor seed is its full stored log, while its header keeps
+   * the original fork value — this field is the in-process construction fact
+   * and is deliberately not persisted.
+   */
+  readonly firstLiveSeq: number;
   constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
   /**
    * An immutable snapshot of the append-only event log. The snapshot is reused

+ 12 - 0
docs/core-data-structures/session.zh.md

@@ -372,6 +372,18 @@ declare class Session {
   readonly header: SessionHeader;
   /** The session identity, derived from its durable header's single copy. */
   get id(): SessionId;
+  /**
+   * The first seq appended IN THIS PROCESS: the length of the constructor
+   * seed (0 without one). Events below it entered through construction —
+   * replay, fork, or resume — and were never published on the `session/event`
+   * firehose (constructor seeds do not emit), so consumers that replay the
+   * log as a publication substitute (telemetry adoption) start here. Distinct
+   * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
+   * session's constructor seed is its full stored log, while its header keeps
+   * the original fork value — this field is the in-process construction fact
+   * and is deliberately not persisted.
+   */
+  readonly firstLiveSeq: number;
   constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
   /**
    * An immutable snapshot of the append-only event log. The snapshot is reused

+ 6 - 5
docs/event-producer-consumer.md

@@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
 | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` |
 | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` |
 | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
@@ -34,10 +34,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:53`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
 | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
 | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
 | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
@@ -48,6 +48,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
+| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
 | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |

+ 13 - 0
docs/module-graph.md

@@ -220,6 +220,10 @@ flowchart TD
     pkg_tasks_local["tasks-local"]
     pkg_tool_tasks["tool-tasks"]
   end
+  subgraph group_telemetry["packages/telemetry"]
+    pkg_session_telemetry["session-telemetry"]
+    pkg_session_telemetry_otel["session-telemetry-otel"]
+  end
   subgraph group_workflow["packages/workflow"]
     pkg_tool_ralph["tool-ralph"]
     pkg_tool_workflow["tool-workflow"]
@@ -491,6 +495,9 @@ flowchart TD
   pkg_tasks --> pkg_brand
   pkg_tasks --> pkg_invariants
   pkg_tasks --> pkg_session
+  pkg_session_telemetry --> pkg_agent
+  pkg_session_telemetry --> pkg_invariants
+  pkg_session_telemetry --> pkg_session
   pkg_workflow --> pkg_agent
   pkg_workflow --> pkg_brand
   pkg_workflow --> pkg_invariants
@@ -570,6 +577,10 @@ flowchart TD
   pkg_tasks_local --> pkg_invariants
   pkg_tasks_local --> pkg_tasks
   pkg_tasks_local --> pkg_timeout
+  pkg_session_telemetry_otel --> pkg_invariants
+  pkg_session_telemetry_otel --> pkg_llm
+  pkg_session_telemetry_otel --> pkg_session
+  pkg_session_telemetry_otel --> pkg_session_telemetry
   pkg_agent_loop --> pkg_agent
   pkg_agent_loop --> pkg_invariants
   pkg_agent_loop --> pkg_llm
@@ -954,6 +965,7 @@ flowchart TD
 | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
 | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
 | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
+| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
 | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) |
 | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
@@ -969,6 +981,7 @@ flowchart TD
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
 | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
+| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
 | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |

File diff suppressed because it is too large
+ 0 - 0
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl


+ 43 - 0
examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts

@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+/**
+ * Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader
+ * composition against it, run one turn whose prompt carries a fixture
+ * credential, then persist everything the collector captured to
+ * `./otlp-captures.json` for the e2e's inspect step.
+ */
+
+import { writeFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
+import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
+
+const configPath = process.argv[2]
+if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path')
+
+const captures: unknown[] = []
+const server = createServer((request, response) => {
+  const chunks: Buffer[] = []
+  request.on('data', chunk => chunks.push(chunk as Buffer))
+  request.on('end', () => {
+    captures.push(JSON.parse(Buffer.concat(chunks).toString()))
+    response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
+  })
+})
+server.listen(0, '127.0.0.1')
+await once(server, 'listening')
+const address = server.address()
+if (address === null || typeof address === 'string') throw new Error('collector has no port')
+process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs`
+
+const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undefined))
+try {
+  // The fixture credential rides the model-visible user message; the exported
+  // copy must scrub it while the canonical log keeps the original bytes.
+  await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' })
+} finally {
+  await ctx.fiber.dispose()
+}
+await writeFile('./otlp-captures.json', JSON.stringify(captures))
+server.close()
+server.closeAllConnections()

+ 28 - 0
examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml

@@ -0,0 +1,28 @@
+# Test-only composition: session-telemetry-otel through the real Loader/app
+# path, exporting to the mock OTLP collector the driver starts (url via env).
+# The redact-rule entry models a deployment mounting its own scrub rule on the
+# telemetry/record waterfall — the seam itself ships no rules.
+- id: cli-mock-llm
+  name: './cli-mock-llm.ts'
+
+- id: telemetry-redact-rule
+  name: './telemetry-redact-rule.ts'
+
+- id: bash
+  name: '@deepseek-ai/dsh-bash-local'
+
+- id: telemetry-otel
+  name: '@deepseek-ai/dsh-session-telemetry-otel'
+  config:
+    exporter:
+      url: !!js process.env.DSH_TELEMETRY_E2E_URL
+
+- id: cli-agent
+  name: '@deepseek-ai/dsh-cli-demo'
+  config:
+    provider: cli-mock
+    model: cli-mock
+    persona: 'Test the session-telemetry-otel plugin.'
+    persistenceRoot: './.sessions'
+    persistenceCompression: 'none'
+    workspaceContext: false

+ 29 - 0
examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts

@@ -0,0 +1,29 @@
+import type { Context } from 'cordis'
+
+/**
+ * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture
+ * credential from body strings, exactly as a real deployment would mount its
+ * own rules on the `telemetry/record` waterfall.
+ */
+
+const SECRET = /sk-e2efixture[0-9]+/g
+const PLACEHOLDER = '[E2E-REDACTED]'
+
+function scrub(value: unknown): unknown {
+  if (typeof value === 'string') return value.replace(SECRET, PLACEHOLDER)
+  if (Array.isArray(value)) return value.map(scrub)
+  if (value !== null && typeof value === 'object') {
+    return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrub(entry)]))
+  }
+  return value
+}
+
+export const name = 'telemetry-redact-rule'
+
+/** Mount the fixture scrub rule onto the redact waterfall. */
+export function apply(ctx: Context): void {
+  ctx.on('telemetry/record', (_record, next) => {
+    const record = next()
+    return { ...record, body: scrub(record.body) }
+  })
+}

+ 1 - 0
examples/package.json

@@ -42,6 +42,7 @@
     "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
     "@deepseek-ai/dsh-session-query": "workspace:*",
     "@deepseek-ai/dsh-session-query-sqlite": "workspace:*",
+    "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*",
     "@deepseek-ai/dsh-spill-local": "workspace:*",
     "@deepseek-ai/dsh-spill-policy": "workspace:*",
     "@deepseek-ai/dsh-tui-demo": "workspace:*",

+ 12 - 0
knip.json

@@ -34,6 +34,8 @@
         "headless-agent/tests/fixtures/goal-domain/seed-goal.ts",
         "headless-agent/tests/fixtures/time-context-driver.ts",
         "headless-agent/tests/fixtures/time-context-mock-llm.ts",
+        "headless-agent/tests/fixtures/telemetry-otel-driver.ts",
+        "headless-agent/tests/fixtures/telemetry-redact-rule.ts",
         "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts",
         "tui-agent/tests/fixtures/tui-scripted-llm.ts",
         "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts",
@@ -182,6 +184,16 @@
         "tests/**/*.ts"
       ]
     },
+    "packages/telemetry/session-telemetry-otel": {
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
+    },
     "packages/util/brand": {
       "project": [
         "src/**/*.ts"

+ 3 - 3
packages/README.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-README.md: 911f18547120eb3dbbc9e42bbcd41e3b6d518cfe
-README.zh.md: d6e1f0bf9b38b40944f8e3cebea3f6d90dcaceb5
+#   pnpm run verify-translation-pairing --write packages/README.md
+README.md: d16e395a42e491461c0862227205931894c27e39
+README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f

+ 1 - 0
packages/README.md

@@ -37,6 +37,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
 | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
 | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
 | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
+| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
 | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
 | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
 | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |

+ 1 - 0
packages/README.zh.md

@@ -37,6 +37,7 @@
 | [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 |
 | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
 | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 |
+| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
 | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
 | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
 | [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |

+ 34 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -822,6 +822,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'telemetry',
+    summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.',
+    methods: [
+      {
+        signature: 'abstract emit(record: TelemetryRecord): void',
+        jsDoc: '/**\n * See {@link TelemetryBackend.emit} — the seam declaration is the contract\'s one home.\n * @param record - the logical record to report; owned by the backend after the call.\n */',
+      },
+      {
+        signature: 'flush?(): void',
+        jsDoc: '/** See {@link TelemetryBackend.flush}. */',
+      },
+      {
+        signature: 'abstract shutdown(): Promise<void>',
+        jsDoc: '/**\n * See {@link TelemetryBackend.shutdown}.\n * @returns resolves when the backend\'s pipeline has quiesced.\n */',
+      },
+    ],
+  },
   {
     key: 'tokenMeter',
     summary: 'Replay owner for one service-wide estimator and isolated per-session folds.',
@@ -1261,6 +1279,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
     jsDoc: '/**\n * Emitted when any prompt provider changes. This registry notification is\n * unfiltered because a global change affects every scope.\n * @mode emit\n */',
     summary: 'Emitted when any prompt provider changes.',
   },
+  {
+    name: 'telemetry/record',
+    mode: 'waterfall',
+    signature: '\'telemetry/record\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord',
+    jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n *   copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */',
+    summary: 'Transform one outbound record before it reaches the backend.',
+  },
   {
     name: 'tools/change',
     mode: 'emit',
@@ -1987,7 +2012,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'Session',
-    declaration: 'export class Session {\n    get surface(): SessionSurface;\n    readonly header: SessionHeader;\n    get id(): SessionId;\n    constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n    get events(): readonly SessionEvent[];\n    get seq(): number;\n    append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n        opts: SurfaceIntent\n    ] : [\n    ]): SessionEvent<T>;\n    requestHeader(): EpochHeader | undefined;\n    deriveMessages(): Message[];\n    deriveEventMessage(event: SessionEvent): Message | null;\n}',
+    declaration: 'export class Session {\n    get surface(): SessionSurface;\n    readonly header: SessionHeader;\n    get id(): SessionId;\n    readonly firstLiveSeq: number;\n    constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n    get events(): readonly SessionEvent[];\n    get seq(): number;\n    append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n        opts: SurfaceIntent\n    ] : [\n    ]): SessionEvent<T>;\n    requestHeader(): EpochHeader | undefined;\n    deriveMessages(): Message[];\n    deriveEventMessage(event: SessionEvent): Message | null;\n}',
   },
   {
     name: 'SessionAvailability',
@@ -2377,6 +2402,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'TaskStatus',
     declaration: 'export type TaskStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
   },
+  {
+    name: 'TelemetryRecord',
+    declaration: 'export interface TelemetryRecord {\n    channel: \'ledger\' | \'ops\';\n    time: number;\n    severity: TelemetrySeverity;\n    attributes: Record<string, string | number>;\n    body: unknown;\n}',
+  },
+  {
+    name: 'TelemetrySeverity',
+    declaration: 'export type TelemetrySeverity = \'info\' | \'warn\' | \'error\';',
+  },
   {
     name: 'TerminalCallView',
     declaration: 'export interface TerminalCallView {\n    card: \'terminal\';\n    title: string;\n    description?: string;\n    cwd?: string;\n}',

+ 14 - 0
packages/core/session/src/index.ts

@@ -305,6 +305,19 @@ export class Session {
     return this.header.id
   }
 
+  /**
+   * The first seq appended IN THIS PROCESS: the length of the constructor
+   * seed (0 without one). Events below it entered through construction —
+   * replay, fork, or resume — and were never published on the `session/event`
+   * firehose (constructor seeds do not emit), so consumers that replay the
+   * log as a publication substitute (telemetry adoption) start here. Distinct
+   * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
+   * session's constructor seed is its full stored log, while its header keeps
+   * the original fork value — this field is the in-process construction fact
+   * and is deliberately not persisted.
+   */
+  readonly firstLiveSeq: number
+
   constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
     if (seed) {
       // Validate the seed to the SAME invariants `append` enforces, so a
@@ -337,6 +350,7 @@ export class Session {
         this.log.push(deepFreeze(snapshot))
       }
     }
+    this.firstLiveSeq = this.log.length
     this.header = snapshotSessionHeader(id, header)
   }
 

+ 6 - 0
packages/telemetry/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/telemetry/README.md
+README.md: 944cb3f9bac6169feddf8b49bc481cfbe7c6fa9d
+README.zh.md: 7be8af93654565cd9fb4d8b4376e0960fd7eb72b

+ 10 - 0
packages/telemetry/README.md

@@ -0,0 +1,10 @@
+# telemetry/
+
+English | [中文](README.zh.md)
+
+Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/record` waterfall (deployment-mounted redaction rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
+
+| Package | Role |
+|---|---|
+| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). |
+| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. |

+ 10 - 0
packages/telemetry/README.zh.md

@@ -0,0 +1,10 @@
+# telemetry/
+
+[English](README.md) | 中文
+
+面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。整套设计归档于[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md):边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/record` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。
+
+| 包 | 职责 |
+|---|---|
+| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、handoff 游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 |
+| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:OTel JS SDK 的日志流水线(`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP 导出器),经透传(passthrough)原样配置。 |

+ 6 - 0
packages/telemetry/session-telemetry-otel/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry-otel/README.md
+README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db
+README.zh.md: f36cfe74146b779c4ddb1101227cb45a06f0968c

+ 41 - 0
packages/telemetry/session-telemetry-otel/README.md

@@ -0,0 +1,41 @@
+# @deepseek-ai/dsh-session-telemetry-otel
+
+English | [中文](README.zh.md)
+
+The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use.
+
+## Config
+
+```yaml
+- id: telemetry-otel
+  name: '@deepseek-ai/dsh-session-telemetry-otel'
+  config:
+    exporter:                # passed verbatim to the SDK's OTLP/HTTP log exporter
+      url: https://collector.example.com/v1/logs
+      headers:
+        authorization: !!js `Bearer ${process.env.OTLP_TOKEN}`
+    processor: {}            # optional; passed verbatim to BatchLogRecordProcessor
+```
+
+`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load (as does a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown). Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
+
+## What leaves the machine
+
+Records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry.
+
+## Field mapping
+
+Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. One consequence of continuing rather than replaying: a turn left open mid-stream and never closed marks the previous process dying inside it. The local log is repaired with synthetic closers at resume, but those repairs are never exported — the wire stream stays faithful to what the crashed process actually shipped, and a later clean `shutdown` marker attests only to the resumed process's own exit.
+
+## Model Experience
+
+None, as the backend only forwards the seam's redacted records into the OTel SDK pipeline; it never contributes to a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move.
+- **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory.

+ 41 - 0
packages/telemetry/session-telemetry-otel/README.zh.md

@@ -0,0 +1,41 @@
+# @deepseek-ai/dsh-session-telemetry-otel
+
+[English](README.md) | 中文
+
+[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。它原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。
+
+## 配置
+
+```yaml
+- id: telemetry-otel
+  name: '@deepseek-ai/dsh-session-telemetry-otel'
+  config:
+    exporter:                # passed verbatim to the SDK's OTLP/HTTP log exporter
+      url: https://collector.example.com/v1/logs
+      headers:
+        authorization: !!js `Bearer ${process.env.OTLP_TOKEN}`
+    processor: {}            # optional; passed verbatim to BatchLogRecordProcessor
+```
+
+`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败(`processor.maxExportBatchSize` 不是正整数时同样如此:SDK 会接受该值,随后却在关闭时因它挂起)。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。
+
+## 哪些数据会离开本机
+
+记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。
+
+## 字段映射
+
+seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用拆卸时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。继续而非回放的一个后果:流中一个开启后再未关闭的轮次,标志着上一个进程死在了该轮次之内。恢复时本地日志会以合成的关闭事件修复,但这些修复绝不导出:导出的流忠实于崩溃进程实际发出的内容,其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。
+
+## 模型体验
+
+无。该后端只把 seam 脱敏后的记录转发进 OTel SDK 流水线;它绝不向模型请求贡献任何内容。
+
+#### KV Cache 影响
+
+无;本包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+- **上游实验性源码树**:`@opentelemetry/sdk-logs` 仍从上游实验性(experimental)源码树发布;SDK API 的变动只会落在本包,也仅落在本包;seam 契约不动。
+- **无真实 collector 覆盖**:所有测试都导出到本地 mock collector;无密钥的 Loader 组合 e2e(`tests/loader-composition.e2e.ts`)在每次运行中都覆盖协议格式(wire format)形态,而面对真实 OTLP 部署的行为(认证、TLS、限流)属于 SDK 导出器文档的职责范围。

+ 53 - 0
packages/telemetry/session-telemetry-otel/package.json

@@ -0,0 +1,53 @@
+{
+  "name": "@deepseek-ai/dsh-session-telemetry-otel",
+  "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "@opentelemetry/api": "^1.9.1",
+    "@opentelemetry/api-logs": "^0.220.0",
+    "@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
+    "@opentelemetry/otlp-exporter-base": "^0.220.0",
+    "@opentelemetry/resources": "^2.9.0",
+    "@opentelemetry/sdk-logs": "^0.220.0",
+    "schemastery": "^3.18.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-session-telemetry": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-telemetry": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 181 - 0
packages/telemetry/session-telemetry-otel/src/index.ts

@@ -0,0 +1,181 @@
+/**
+ * OpenTelemetry backend for the DeepSeek Harness telemetry seam.
+ *
+ * Composes the OTel JS SDK as-is — a `LoggerProvider` with a
+ * `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each
+ * record handed over by the seam onto `logger.emit()`. Per the seam's
+ * boundary axiom, everything downstream of that call (batching, retry,
+ * queueing, loss policy) is the SDK's documented behavior, configured
+ * verbatim through the `exporter`/`processor` passthroughs; this package
+ * adds no knobs of its own on top of them.
+ *
+ * @module @deepseek-ai/dsh-session-telemetry-otel
+ */
+
+import { createRequire } from 'node:module'
+import z from 'schemastery'
+import type { Context } from 'cordis'
+import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry'
+import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
+import {
+  BatchLogRecordProcessor,
+  LoggerProvider,
+  type BatchLogRecordProcessorOptions,
+} from '@opentelemetry/sdk-logs'
+import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
+import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'
+import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs'
+import { resourceFromAttributes } from '@opentelemetry/resources'
+
+// The package's own manifest is the single source of the instrumentation-scope
+// version (same pattern as dsh-llm's attribution identity).
+const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
+
+/**
+ * Plugin configuration: two verbatim SDK option shapes plus nothing else.
+ * `exporter.url` is the one field this package validates itself — required,
+ * no default, must parse as an `http(s)` URL — because a missing endpoint
+ * must fail at plugin load, not at first export.
+ */
+export interface Config {
+  /**
+   * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
+   * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
+   * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
+   * is the one field this package requires and validates itself.
+   */
+  exporter?: OTLPExporterNodeConfigBase & {
+    /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */
+    url?: string
+  }
+  /**
+   * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
+   * which this plugin fills); the SDK owns and documents these knobs.
+   */
+  processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
+}
+
+/**
+ * Schemastery validator for {@link Config}; cordis runs it before the plugin
+ * starts. Shape-level only — the load-bearing `exporter.url` check lives in
+ * the constructor so its error message names the field. Both slots are opaque
+ * passthroughs: the SDK owns their shapes and validates its own options;
+ * re-declaring them field-by-field here would violate the boundary axiom
+ * (and silently drop every field not re-declared).
+ */
+export const Config: z<Config> = z.object({
+  exporter: z.any(),
+  processor: z.any(),
+})
+
+/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */
+const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
+  info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
+  warn: { severityNumber: SeverityNumber.WARN, severityText: 'WARN' },
+  error: { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' },
+}
+
+/**
+ * The backend plugin — the only entry a deployment loads. Constructing it
+ * wires the SDK pipeline, registers the `telemetry` service (duplicate load
+ * throws, cordis' standard duplicate-service behavior), and composes the
+ * seam's {@link TelemetryCoordinator}, which installs the capture side onto
+ * this fiber.
+ */
+export class TelemetryOtel extends Telemetry {
+  static inject = ['sessions']
+  static Config = Config
+
+  private readonly provider: LoggerProvider
+  private readonly ledger: Logger
+  private readonly ops: Logger
+
+  constructor(ctx: Context, config: Config) {
+    super(ctx)
+    const url = config.exporter?.url
+    if (url === undefined || url.length === 0) {
+      throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)')
+    }
+    let parsed: URL
+    try {
+      parsed = new URL(url)
+    } catch {
+      // Re-thrown as a config error: the only way here is a malformed url string.
+      throw new Error(`session-telemetry-otel: exporter.url is not a valid URL: ${JSON.stringify(url)}`)
+    }
+    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+      throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`)
+    }
+    // The one processor field checked beyond the SDK's own validation: the
+    // SDK accepts a non-positive batch size, but its shutdown drain then
+    // splices empty batches without consuming the queue — dispose would hang
+    // forever with records queued. Misconfiguration fails at load instead.
+    const batchSize = config.processor?.maxExportBatchSize
+    if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
+      throw new Error(`session-telemetry-otel: processor.maxExportBatchSize must be a positive integer, got ${String(batchSize)}`)
+    }
+    this.provider = new LoggerProvider({
+      resource: resourceFromAttributes({
+        'service.name': APP_IDENTITY.product,
+        'service.version': APP_IDENTITY.version,
+      }),
+      processors: [
+        new BatchLogRecordProcessor({
+          ...config.processor,
+          // The complete validated exporter object, verbatim: every SDK
+          // option (`timeoutMillis`, `compression`, `keepAlive`, …) reaches
+          // the exporter — rebuilding selected fields here would silently
+          // ignore the rest. App identity travels in the Resource
+          // (service.name/version); the transport-level user-agent is the
+          // SDK's own, per the axiom.
+          exporter: new OTLPLogExporter(config.exporter),
+        }),
+      ],
+    })
+    this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version)
+    this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version)
+    new TelemetryCoordinator(ctx, this)
+  }
+
+  /**
+   * Map one seam record onto the SDK logger for its channel — a synchronous
+   * enqueue into the batch processor's queue.
+   * @param record - the logical record handed over by the coordinator.
+   */
+  emit(record: TelemetryRecord): void {
+    const logger = record.channel === 'ops' ? this.ops : this.ledger
+    logger.emit({
+      timestamp: record.time,
+      observedTimestamp: record.time,
+      ...SEVERITY[record.severity],
+      // JSON-serializable by the seam's contract (validated at Session.append),
+      // which is exactly the AnyValue subset.
+      body: record.body as AnyValue,
+      attributes: record.attributes,
+    })
+  }
+
+  // The seam's optional flush() hint is deliberately NOT implemented. The
+  // batch processor exports on its own cadence (`processor.scheduledDelayMillis`,
+  // the SDK's documented knob), and this backend is the SDK pipeline's only
+  // caller — forwarding the hint to `forceFlush()` was the sole source of
+  // concurrent flushes, whose undocumented interactions with shutdown's
+  // internal drain (concurrent-flush guard, provider-level flush timeout)
+  // silently dropped tail records. Removal history and the revival trigger:
+  // the revival Agent Note.
+
+  /**
+   * Delegate disposal to the SDK's shutdown contract: drain the queue and
+   * quiesce. With no concurrent `forceFlush()` in the process (see above),
+   * shutdown's internal drain is complete — everything emitted before this
+   * call, including the coordinator's dispose-time `shutdown` markers, is
+   * exported before the exporter closes. Awaited (and error-contained) by
+   * the coordinator's disposer.
+   * @returns resolves when the SDK pipeline has quiesced.
+   */
+  shutdown(): Promise<void> {
+    return this.provider.shutdown()
+  }
+}
+
+export default TelemetryOtel

+ 32 - 0
packages/telemetry/session-telemetry-otel/src/invariant.ts

@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry-otel`.
+ * @module @deepseek-ai/dsh-session-telemetry-otel/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry-otel'
+
+/** Cordis companion plugin name. */
+export const name = 'session-telemetry-otel-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the backend forwards seam records into the OTel SDK's
+ * in-process pipeline and appends nothing to any session; its only observable
+ * effects (batching, export) happen inside the SDK past the seam's boundary
+ * axiom, out of reach of an independent companion.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 99 - 0
packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts

@@ -0,0 +1,99 @@
+/**
+ * REAL-composition tier: boot the examples-owned telemetry Loader fixture as
+ * a subprocess (per testing policy, through the same app/boot path a
+ * deployment uses), run one mocked-model turn with a real bash round trip,
+ * and assert against what the mock OTLP collector actually received on the
+ * wire: ledger mirroring, the deployment-mounted redact rule applied to the
+ * exported copy, ops markers, and the untouched canonical log.
+ */
+
+import { readFile, readdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
+
+const driver = fileURLToPath(new URL(
+  '../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts',
+  import.meta.url,
+))
+const configPath = fileURLToPath(new URL(
+  '../../../../examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml',
+  import.meta.url,
+))
+const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+
+const FIXTURE_SECRET = 'sk-e2efixture1234567890'
+const FIXTURE_PLACEHOLDER = '[E2E-REDACTED]'
+
+interface OtlpLogRecord {
+  attributes?: { key: string; value: Record<string, unknown> }[]
+  body?: unknown
+}
+
+interface OtlpCapture {
+  resourceLogs: {
+    scopeLogs: {
+      scope: { name: string }
+      logRecords: OtlpLogRecord[]
+    }[]
+  }[]
+}
+
+async function jsonlFiles(dir: string): Promise<string[]> {
+  const entries = await readdir(dir, { withFileTypes: true })
+  const paths = await Promise.all(entries.map(async (entry) => {
+    const path = join(dir, entry.name)
+    if (entry.isDirectory()) return jsonlFiles(path)
+    return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
+  }))
+  return paths.flat()
+}
+
+describe('session-telemetry-otel through a real headless cordis.yml', () => {
+  it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => {
+    let captures: OtlpCapture[] = []
+    let logContent = ''
+    const { stderr } = await runLoaderSmoke({
+      label: 'session-telemetry-otel loader smoke',
+      tempDirPrefix: 'telemetry-otel-e2e-',
+      binScript: driver,
+      libBinScript: driver,
+      configPath,
+      tsconfigPath: repoTsconfig,
+      inspect: async (cwd) => {
+        captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[]
+        const logs = await jsonlFiles(join(cwd, '.sessions'))
+        expect(logs).toHaveLength(1)
+        logContent = await readFile(logs[0] as string, 'utf8')
+      },
+    })
+    expect(stderr).not.toContain('UNHANDLED')
+
+    const records = captures.flatMap(capture => capture.resourceLogs.flatMap(resource =>
+      resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record })))))
+    expect(records.length).toBeGreaterThan(0)
+
+    const eventTypes = records.flatMap(({ record }) =>
+      record.attributes?.flatMap(attribute =>
+        attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string'
+          ? [attribute.value['stringValue']]
+          : []) ?? [])
+    for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) {
+      expect(eventTypes, expected).toContain(expected)
+    }
+    expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true)
+
+    // The deployment-mounted rule on the wire: the fixture credential never
+    // leaves the process, its surrounding prose does, and the placeholder
+    // marks the spot — the seam itself ships no rules.
+    const wire = JSON.stringify(captures)
+    expect(wire).not.toContain(FIXTURE_SECRET)
+    expect(wire).toContain(FIXTURE_PLACEHOLDER)
+    expect(wire).toContain('prove telemetry with key')
+
+    // The canonical session log is never rewritten.
+    expect(logContent).toContain(FIXTURE_SECRET)
+    expect(logContent).not.toContain(FIXTURE_PLACEHOLDER)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+})

+ 237 - 0
packages/telemetry/session-telemetry-otel/tests/otel.spec.ts

@@ -0,0 +1,237 @@
+/**
+ * OTel backend unit tier: wire assertions against a scripted `node:http`
+ * mock collector through the SDK's REAL pipeline (BatchLogRecordProcessor →
+ * OTLP/HTTP JSON), config fail-loud cases, and the real-Loader-path guard
+ * for the default-exported Service class.
+ */
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { createServer, type Server } from 'node:http'
+import { once } from 'node:events'
+import { gunzipSync } from 'node:zlib'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import TelemetryOtel, { Config } from '../src/index.ts'
+
+interface Capture {
+  headers: import('node:http').IncomingHttpHeaders
+  body: OtlpLogsRequest
+}
+
+/** Just the slice of ExportLogsServiceRequest JSON these assertions touch. */
+interface OtlpLogsRequest {
+  resourceLogs: {
+    resource: { attributes: { key: string; value: { stringValue?: string } }[] }
+    scopeLogs: {
+      scope: { name: string }
+      logRecords: {
+        timeUnixNano: string
+        severityNumber: number
+        severityText: string
+        attributes?: { key: string; value: Record<string, unknown> }[]
+      }[]
+    }[]
+  }[]
+}
+
+const servers: Server[] = []
+
+afterEach(async () => {
+  for (const server of servers.splice(0)) {
+    server.close()
+    server.closeAllConnections()
+  }
+})
+
+async function mockCollector(
+  beforeRespond?: (requestIndex: number) => Promise<void> | void,
+): Promise<{ url: string; captures: Capture[] }> {
+  const captures: Capture[] = []
+  let requestIndex = 0
+  const server = createServer((request, response) => {
+    const chunks: Buffer[] = []
+    request.on('data', chunk => chunks.push(chunk as Buffer))
+    request.on('end', () => {
+      const index = requestIndex++
+      void (async () => {
+        await beforeRespond?.(index)
+        const raw = Buffer.concat(chunks)
+        const body = request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw
+        captures.push({
+          headers: request.headers,
+          body: JSON.parse(body.toString()) as OtlpLogsRequest,
+        })
+        response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
+      })()
+    })
+  })
+  servers.push(server)
+  server.listen(0, '127.0.0.1')
+  await once(server, 'listening')
+  const address = server.address()
+  if (address === null || typeof address === 'string') throw new Error('no port')
+  return { url: `http://127.0.0.1:${address.port}/v1/logs`, captures }
+}
+
+async function boot(url: string) {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  const fiber = await ctx.plugin(TelemetryOtel, {
+    exporter: { url, headers: { authorization: 'Bearer test-token' } },
+  })
+  return { ctx, fiber }
+}
+
+function allRecords(captures: Capture[]) {
+  return captures.flatMap(c => c.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s =>
+    s.logRecords.map(record => ({ scope: s.scope.name, record })))))
+}
+
+describe('TelemetryOtel wire', () => {
+  it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => {
+    const { url, captures } = await mockCollector()
+    const { ctx, fiber } = await boot(url)
+    const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
+    await fiber.dispose()
+
+    expect(captures.length).toBeGreaterThan(0)
+    const first = captures[0]!
+    const authorization: string | undefined = first.headers.authorization
+    expect(authorization).toBe('Bearer test-token')
+
+    const resource = first.body.resourceLogs[0]!.resource.attributes
+    expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } })
+
+    const records = allRecords(captures)
+    const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel')
+    const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops')
+
+    const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
+    expect(start).toBeDefined()
+    expect(start?.record.severityNumber).toBe(9)
+    expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n)
+    expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } })
+
+    const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end'))
+    expect(end?.record.severityNumber).toBe(17)
+    expect(end?.record.severityText).toBe('ERROR')
+
+    expect(ops).toHaveLength(1)
+    expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
+  })
+
+  it('drains records enqueued after a timer export began: dispose during an in-flight batch', async () => {
+    // The backend implements NO flush() — the batch processor exports on its
+    // own cadence, and shutdown's internal drain is complete exactly because
+    // nothing in the process calls forceFlush() concurrently (the SDK's
+    // concurrent-flush guard skips draining otherwise). Pin that: hold the
+    // collector's response to the timer-triggered export open across
+    // disposal, and the dispose-time shutdown marker (enqueued after that
+    // batch's snapshot) must still arrive.
+    const gate = Promise.withResolvers<boolean>()
+    const arrived = Promise.withResolvers<boolean>()
+    const { url, captures } = await mockCollector(async (index) => {
+      if (index === 0) {
+        arrived.resolve(true)
+        await gate.promise
+      }
+    })
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const fiber = await ctx.plugin(TelemetryOtel, {
+      exporter: { url },
+      processor: { scheduledDelayMillis: 10 },
+    })
+    const session = ctx.sessions.create(SessionId('drain'), { meta: {} })
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    await arrived.promise
+
+    const disposal = fiber.dispose()
+    // Let disposal reach the backend's shutdown while the export is held open.
+    await new Promise(resolve => setTimeout(resolve, 50))
+    gate.resolve(true)
+    await disposal
+
+    const ops = allRecords(captures).filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops')
+    expect(ops).toHaveLength(1)
+    expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
+  })
+
+  it('passes exporter options beyond url and headers through to the SDK exporter', async () => {
+    const { url, captures } = await mockCollector()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    // `compression` is a documented SDK exporter option; the advertised
+    // verbatim passthrough must hand it (and every other field) to the
+    // exporter rather than silently rebuilding url/headers only.
+    const fiber = await ctx.plugin(TelemetryOtel, {
+      exporter: { url, compression: 'gzip' },
+    } as Config)
+    const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    await fiber.dispose()
+
+    expect(captures.length).toBeGreaterThan(0)
+    expect(captures[0]!.headers['content-encoding']).toBe('gzip')
+    const types = allRecords(captures).flatMap(({ record }) =>
+      record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? [])
+    expect(types).toContain('turn/start')
+  })
+
+  it('maps the warn severity and leaves the seam flush hint unimplemented', async () => {
+    const { url, captures } = await mockCollector()
+    const { ctx, fiber } = await boot(url)
+    const session = ctx.sessions.create(SessionId('warn'), { meta: {} })
+    session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' })
+    // No flush(): the coordinator's optional-call forwarding no-ops, and the
+    // batch processor owns export cadence end to end (see the backend note).
+    expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false)
+    await fiber.dispose()
+    const blocked = allRecords(captures).find(r =>
+      r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'prompt/blocked'))
+    expect(blocked?.record.severityNumber).toBe(13)
+  })
+})
+
+describe('TelemetryOtel config fails loud', () => {
+  it.each([
+    [{}, /exporter\.url is required/],
+    [{ exporter: { url: '' } }, /exporter\.url is required/],
+    [{ exporter: { url: 'not a url' } }, /not a valid URL/],
+    [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
+    // The SDK accepts a non-positive batch size but its shutdown drain then
+    // splices empty batches forever — dispose would hang, so reject at load.
+    [{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
+    [{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
+  ])('rejects %j at plugin load', async (config, message) => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message)
+  })
+})
+
+describe('dsh-session-telemetry-otel real-load-path guard', () => {
+  it('keeps the Service class with inject/Config through unwrapExports', async () => {
+    const module = await import('../src/index.ts')
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(module) as typeof TelemetryOtel
+    expect(unwrapped).toBe(TelemetryOtel)
+    expect(unwrapped.inject).toEqual(['sessions'])
+    expect(typeof unwrapped.Config).toBe('function')
+  })
+
+  it('boots through the unwrapped class and registers ctx.telemetry', async () => {
+    const { url } = await mockCollector()
+    const module = await import('../src/index.ts')
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(module) as Parameters<Context['plugin']>[0]
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const fiber = await ctx.plugin(unwrapped, { exporter: { url } })
+    expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel)
+    await fiber.dispose()
+  })
+})

+ 33 - 0
packages/telemetry/session-telemetry-otel/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../session-telemetry"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 6 - 0
packages/telemetry/session-telemetry/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry/README.md
+README.md: a3820ee8e91a5c513c08df76b149cf3a670ee2ca
+README.zh.md: d607a4ab4d953242ede53ff0b5e50bdf69e29ea4

+ 42 - 0
packages/telemetry/session-telemetry/README.md

@@ -0,0 +1,42 @@
+# @deepseek-ai/dsh-session-telemetry
+
+English | [中文](README.zh.md)
+
+The telemetry seam: the CAPTURE side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
+
+## The backend contract
+
+`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor.
+
+## Capture points
+
+The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`).
+
+## The redact waterfall
+
+Every record passes the `telemetry/record` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten.
+
+## The handoff cursor
+
+A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error.
+
+## The fixed chunk projection
+
+Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are dropped at capture and never advance the cursor. That one chunk is the stream-started signal: `step/start` + first-chunk presence + `assistant/message` presence + the `turn/end` reason distinguish "the request never started" from "the stream died midway" without chunk volume, and time-to-first-token stays computable. Chunk elision makes `seq` gaps routine on the wire — a gap is never a loss signal. Every other event type, including ones merged by plugins this package never heard of, passes through whole.
+
+## The logical record
+
+`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id`/`session.seed_length` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`.
+
+## Model Experience
+
+None, as the seam only observes the session stream and hands redacted copies to a reporting backend; it never contributes to a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
+- **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set.

+ 42 - 0
packages/telemetry/session-telemetry/README.zh.md

@@ -0,0 +1,42 @@
+# @deepseek-ai/dsh-session-telemetry
+
+[English](README.md) | 中文
+
+遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。塑造本包一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
+
+## 后端契约
+
+`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`。
+
+## 捕获点
+
+协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;轮次封闭机制在结构上决定了这些错误进不了日志)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。
+
+## 脱敏 waterfall
+
+每条记录在投影与 `emit()` 之间都要经过 `telemetry/record` waterfall(瀑布式事件),这是该 seam 的擦除扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式扣下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。
+
+## handoff 游标
+
+一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。
+
+## 固定分片投影
+
+每个 `(turn, step)` 只发出第一条 `assistant/chunk`;其余分片在捕获时丢弃,且绝不推进游标。这一条分片就是「流已开始」的信号:`step/start`、首分片是否存在、`assistant/message` 是否存在,加上 `turn/end` 的原因,无需分片流量即可区分「请求从未开始」与「流中途夭折」,首个 token 延迟(time-to-first-token)也仍然可以计算。分片省略使导出流中的 `seq` 缺口成为常态:缺口绝不是丢失信号。其余所有事件类型都会完整透传,包括本包从未听说过的插件所合并的事件类型。
+
+## 逻辑记录
+
+`TelemetryRecord` 包含:`channel`(`ledger` | `ops`)、`time`(epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError` 与 `turn/end` 的错误原因映射为 ERROR,`prompt/blocked` 映射为 WARN,其余为 INFO,包括结果语义仍归其所有者的插件合并事件类型)、只含身份信息的 `attributes`(`session.id`、`event.type`、`event.seq`,header 中存在时再加 `session.cwd`/`session.parent_id`/`session.seed_length`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op`(`agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。
+
+## 模型体验
+
+无。该 seam 只观察会话流,并把脱敏后的副本交给上报后端;它绝不向模型请求贡献任何内容。
+
+#### KV Cache 影响
+
+无;本包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
+- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。

+ 41 - 0
packages/telemetry/session-telemetry/package.json

@@ -0,0 +1,41 @@
+{
+  "name": "@deepseek-ai/dsh-session-telemetry",
+  "description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 280 - 0
packages/telemetry/session-telemetry/src/coordinator.ts

@@ -0,0 +1,280 @@
+/**
+ * Capture coordinator: the seam's upstream half. Subscribes to the session
+ * firehose plus the one live-bus relay (`agent/error`), applies the fixed
+ * chunk projection, builds logical records, runs each through the
+ * `telemetry/record` waterfall (deployment-mounted redaction rules;
+ * pass-through when none), and hands the result to the backend — synchronously, with every
+ * handler self-contained so a failing backend can never starve other
+ * subscribers (cordis `emit` is stop-on-throw) or touch the agent loop.
+ * Composed by a backend in its constructor.
+ *
+ * @module @deepseek-ai/dsh-session-telemetry/coordinator
+ */
+
+import type { Context } from 'cordis'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts'
+
+/**
+ * The handoff cursor: per session, the highest `seq` handed to a backend.
+ * Deliberately MODULE-scope ambient state — a narrow, documented exception
+ * to the registrations-are-effects discipline: cordis has no HMR
+ * state-handover API, and keying by the `Session` object (which belongs to
+ * the session store and outlives any telemetry fiber) is the only in-process
+ * lifetime that lets a re-adopting fiber resume instead of re-handing
+ * history. Entries die with their sessions; a missing entry safely means
+ * "re-hand everything". Advanced only at emit time — the cursor marks
+ * handed-off, not delivered.
+ */
+const handoffCursor = new WeakMap<Session, number>()
+
+/**
+ * Install the telemetry capture side onto a context for one backend.
+ *
+ * Registers the persistence-coordinator listener set plus the `agent/error`
+ * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and
+ * sweeps already-live sessions (a hot reload does not replay
+ * `session/created`). A `session/disposed` emits the session's `shutdown`
+ * operational record — the marker rides the session's own termination edge,
+ * where receivers key crash detection — and retires it from the adopted set,
+ * so a long-lived backend neither retains closed sessions (and their frozen
+ * event logs) nor re-marks them at unload. Disposal marks the sessions still
+ * alive at teardown (their own edge would fire unobserved) and then awaits
+ * the backend's `shutdown()`; a failure there warns instead of throwing —
+ * best-effort reporting must not fail application teardown.
+ */
+export class TelemetryCoordinator {
+  /**
+   * Sessions adopted by THIS fiber and still live, for double-adoption
+   * protection and the teardown sweep of unmarked sessions;
+   * `session/disposed` marks and retires entries.
+   */
+  private readonly adopted = new Set<Session>()
+  /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */
+  private readonly chunkSeen = new WeakMap<Session, Set<string>>()
+
+  /**
+   * @param ctx - the composing backend's context; listeners bind to its fiber.
+   * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding.
+   */
+  constructor(
+    private readonly ctx: Context,
+    private readonly backend: TelemetryBackend,
+  ) {
+    ctx.on('session/created', (session) => {
+      this.adopt(session)
+    })
+    // The session's own termination edge: emit the shutdown marker HERE —
+    // receivers classify a session with activity and no marker as crashed,
+    // so a normally closed session in a long-running host must get its
+    // marker at disposal, not never. Then retire: the projection/cursor
+    // WeakMaps die with the Session object; only the strong adopted set
+    // needs the explicit release.
+    ctx.on('session/disposed', (session) => {
+      this.contain(() => {
+        if (!this.adopted.delete(session)) return
+        this.handOff(shutdownRecord(session))
+      })
+    })
+    ctx.on('session/event', (session, event) => {
+      this.contain(() => {
+        this.capture(session, event)
+      })
+    })
+    // Parallel listeners are awaited by the loop at turn end; returning void
+    // (not the SDK's flush promise) is the turn-latency contract.
+    ctx.on('session/flush', (session) => {
+      this.contain(() => {
+        this.hintFlush(session)
+      })
+    })
+    ctx.on('agent/error', (agent, turn, step, error) => {
+      this.contain(() => {
+        this.relayAgentError(agent, turn, step, error)
+      })
+    })
+    ctx.effect(() => async () => {
+      // Sessions still adopted here are alive through a whole-application
+      // teardown (their own disposal edge will fire after telemetry is gone,
+      // unobserved) — mark them now so the receiver sees a clean stop of
+      // observation rather than a crash-shaped silence.
+      for (const session of this.adopted) {
+        this.contain(() => {
+          this.handOff(shutdownRecord(session))
+        })
+      }
+      try {
+        await this.backend.shutdown()
+      } catch (error) {
+        this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`)
+      }
+    }, 'telemetry capture')
+    for (const session of ctx.sessions.list()) {
+      this.adopt(session)
+    }
+  }
+
+  /**
+   * Adopt a session: replay its log THROUGH the projection from the handoff
+   * cursor, then rely on the firehose for everything after. When no cursor
+   * survived, replay starts at the session's construction boundary
+   * (`firstLiveSeq`), not seq 0: constructor seeds never publish on the
+   * firehose, and their content already left the process under another
+   * identity — the same id in a previous process (resume) or the parent's
+   * stream (fork, stitched by receivers via `session.seed_length`). Events
+   * at or below the start still feed the projection state (first-chunk
+   * tracking) without being re-handed, so a resumed fiber drops mid-step
+   * chunk continuations exactly like the fiber that saw the step begin. The
+   * cost, accepted with the seam's at-most-once stance: a resume no longer
+   * backfills records a previous process failed to deliver.
+   * @param session - the live session to adopt; a second adoption is a no-op.
+   */
+  private adopt(session: Session): void {
+    if (this.adopted.has(session)) return
+    this.adopted.add(session)
+    const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1
+    // Containment is PER EVENT, matching the firehose: one rejected record
+    // is withheld fail-closed while the rest of the historical replay
+    // proceeds — wrapping the whole loop would let a single failure silently
+    // skip the remainder of the log on an already-adopted session.
+    for (const event of session.events) {
+      this.contain(() => {
+        if (event.seq <= cursor) this.track(session, event)
+        else this.capture(session, event)
+      })
+    }
+  }
+
+  /** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */
+  private track(session: Session, event: SessionEvent): void {
+    if (event.type === 'assistant/chunk') {
+      this.seen(session).add(`${event.data.turn}:${event.data.step}`)
+    }
+  }
+
+  /** Project one event and hand it to the backend, advancing the cursor on handoff. */
+  private capture(session: Session, event: SessionEvent): void {
+    if (event.type === 'assistant/chunk') {
+      const key = `${event.data.turn}:${event.data.step}`
+      const seen = this.seen(session)
+      // Fixed chunk projection: only the first chunk of each (turn, step)
+      // ships — the stream-started signal; content is byte-complete in the
+      // step's assembled assistant/message. Dropped chunks do not advance
+      // the cursor, so re-adoption re-drops them deterministically.
+      if (seen.has(key)) return
+      seen.add(key)
+    }
+    this.handOff({
+      channel: 'ledger',
+      time: event.time,
+      severity: severityOf(event),
+      attributes: identityOf(session, event),
+      // The live event object is mutable and the backend serializes later;
+      // append-time validation guarantees this clone cannot throw.
+      body: structuredClone(event.data),
+    })
+    handoffCursor.set(session, event.seq)
+  }
+
+  /**
+   * Run the `telemetry/record` waterfall over one record and hand the result
+   * to the backend. The innermost `next` passes the record through unchanged
+   * — the seam ships no rules; exported data is as clean as the listeners a
+   * deployment mounts. Callers run inside {@link contain}, so a throwing
+   * rule withholds the record instead of reaching the loop (fail-closed).
+   */
+  private handOff(record: TelemetryRecord): void {
+    this.backend.emit(this.ctx.waterfall('telemetry/record', record, () => record))
+  }
+
+  /** Forward the turn-end boundary to the backend's optional flush hint. */
+  private hintFlush(session: Session): void {
+    if (this.adopted.has(session)) this.backend.flush?.()
+  }
+
+  /** Relay one `agent/error` bus emission as an `agent-error` operational record. */
+  private relayAgentError(agent: Agent, turn: number, step: number, error: Error): void {
+    this.handOff({
+      channel: 'ops',
+      time: Date.now(),
+      severity: 'error',
+      attributes: {
+        'telemetry.op': 'agent-error',
+        'session.id': String(agent.session.id),
+        'agent.id': agent.id,
+        'error.name': error.name,
+        turn,
+        step,
+      },
+      body: { name: error.name, message: error.message },
+    })
+  }
+
+  /** Lazily create the per-session first-chunk tracking set. */
+  private seen(session: Session): Set<string> {
+    let set = this.chunkSeen.get(session)
+    if (!set) this.chunkSeen.set(session, set = new Set())
+    return set
+  }
+
+  /**
+   * Run one capture-side step with its exception contained: cordis `emit`
+   * is stop-on-throw, so a throwing listener would starve every subscriber
+   * registered after this plugin — nothing from the backend may escape.
+   */
+  private contain(step: () => void): void {
+    try {
+      step()
+    } catch (error) {
+      this.ctx.logger.warn(`telemetry: capture step failed: ${String(error)}`)
+    }
+  }
+}
+
+/**
+ * Build the per-session clean-exit marker: emitted at the session's own
+ * disposal edge, or at coordinator dispose for sessions still alive then.
+ */
+function shutdownRecord(session: Session): TelemetryRecord {
+  return {
+    channel: 'ops',
+    time: Date.now(),
+    severity: 'info',
+    attributes: { 'telemetry.op': 'shutdown', 'session.id': String(session.id) },
+    body: { op: 'shutdown' },
+  }
+}
+
+/** Map an event's own outcome flag to the pre-baked alerting severity. */
+function severityOf(event: SessionEvent): TelemetrySeverity {
+  switch (event.type) {
+    case 'tool/result':
+      return event.data.isError ? 'error' : 'info'
+    case 'turn/end':
+      return event.data.reason.kind === 'error' ? 'error' : 'info'
+    case 'prompt/blocked':
+      return 'warn'
+    default:
+      // Merge-extensible fall-through (no assertNever): event types this seam
+      // does not depend on — including plugin-merged ones it never heard of —
+      // pass through as info; their owners' outcome semantics stay theirs.
+      return 'info'
+  }
+}
+
+/** Build the minimal identity attributes: envelope plus self-contained header facts. */
+function identityOf(session: Session, event: SessionEvent): Record<string, string | number> {
+  const attributes: Record<string, string | number> = {
+    'session.id': String(session.id),
+    'event.type': event.type,
+    'event.seq': event.seq,
+  }
+  const { cwd, parentSession, seedLength } = session.header
+  if (cwd !== undefined) attributes['session.cwd'] = cwd
+  if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession)
+  // The durable fork boundary: a forked stream starts here, and its prefix
+  // lives in the parent's stream — receivers stitch on (parent_id, seed_length).
+  if (seedLength !== undefined) attributes['session.seed_length'] = seedLength
+  return attributes
+}

+ 156 - 0
packages/telemetry/session-telemetry/src/index.ts

@@ -0,0 +1,156 @@
+/**
+ * Telemetry seam for the DeepSeek Harness.
+ *
+ * The seam owns the CAPTURE side of session-event reporting — which records
+ * exist (the chunk projection), what they carry (the logical record), when
+ * they are handed over (adoption, the per-append firehose, lifecycle
+ * forwarding), and the HMR handoff cursor. Everything downstream of
+ * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the
+ * reporting SDK's territory and is deliberately not modelled here. The
+ * design and its trade-offs are pinned in
+ * .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md.
+ *
+ * @module @deepseek-ai/dsh-session-telemetry
+ */
+
+import { Context, Service } from 'cordis'
+
+declare module 'cordis' {
+  interface Context {
+    telemetry: Telemetry
+  }
+
+  interface Events {
+    /**
+     * Transform one outbound record before it reaches the backend. This
+     * waterfall is the seam's redaction extension point. It ships NO rules
+     * of its own: the
+     * innermost `next()` passes the record through unchanged, and with no
+     * listener mounted records reach the backend as captured, so exported
+     * data is exactly as clean as the rules a deployment mounts. Listeners
+     * stack by transforming `next()`'s return value; returning without
+     * `next()` replaces everything beneath. Dispatched synchronously on the
+     * capture hot path inside the coordinator's containment: a throwing
+     * listener withholds that one record (fail-closed) and never reaches the
+     * agent loop. Redaction applies to the exported copy only; the canonical
+     * session log is never rewritten.
+     * @param record - the candidate record, already the coordinator's own deep
+     *   copy; listeners return a (possibly new) record and must not mutate it.
+     * @mode waterfall
+     */
+    'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
+  }
+}
+
+/**
+ * Severity of a telemetry record, pre-mapped at capture so a receiver can
+ * alert with zero configuration: `error` for events whose own outcome flag
+ * says so (`tool/result.isError`, `turn/end` error reasons) and for
+ * `agent-error` operational records, `warn` for `prompt/blocked`, `info`
+ * for everything else — including event types merged by other packages,
+ * whose outcome semantics stay with their owners.
+ */
+export type TelemetrySeverity = 'info' | 'warn' | 'error'
+
+/**
+ * One logical record handed to a backend — the seam's whole outbound
+ * vocabulary. Ledger records mirror session-log events one-to-one;
+ * operational records (`channel: 'ops'`) carry the two signals with no log
+ * home (`agent-error`, `shutdown`) and deliberately omit `event.seq`-style
+ * identity so they can never be mistaken for ledger rows.
+ */
+export interface TelemetryRecord {
+  /** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
+  channel: 'ledger' | 'ops'
+  /** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
+  time: number
+  /** Pre-mapped alerting severity; see {@link TelemetrySeverity}. */
+  severity: TelemetrySeverity
+  /**
+   * Identity attributes, deliberately minimal: ledger records carry
+   * `session.id`, `event.type`, `event.seq`, plus `session.cwd` /
+   * `session.parent_id` when the header has them; ops records carry
+   * `telemetry.op`, `session.id`, and (for `agent-error`) `agent.id`,
+   * `turn`, `step`, `error.name`. Anything recoverable from the body is
+   * intentionally NOT duplicated here.
+   */
+  attributes: Record<string, string | number>
+  /**
+   * The complete payload: a deep copy of the session event's `data` for
+   * ledger records (JSON-serializable by `Session.append`'s own
+   * validation), or the op payload for ops records. Never mutated after
+   * handoff.
+   */
+  body: unknown
+}
+
+/**
+ * The backend contract the coordinator hands records to — the minimum any
+ * reporting SDK satisfies with zero bending. {@link Telemetry} is its
+ * service-registered form; tests compose the coordinator with a bare
+ * implementation of this interface.
+ */
+export interface TelemetryBackend {
+  /**
+   * Hand one record to the backend's pipeline. MUST be a non-blocking
+   * enqueue — the coordinator calls this synchronously from the
+   * `session/event` hot path, so anything slower than a queue push would tax
+   * the agent loop. Errors thrown here are contained by the coordinator and
+   * logged; they never reach the loop.
+   * @param record - the logical record to report; owned by the backend after the call.
+   */
+  emit(record: TelemetryRecord): void
+  /**
+   * Optional hint that a natural boundary (turn end) passed — a backend may
+   * forward it to its SDK's flush so records land at turn boundaries. Called
+   * fire-and-forget; implementations must not block and must not throw
+   * meaningfully (the coordinator contains exceptions). Most backends should
+   * leave this unimplemented and let their SDK's own batching cadence govern
+   * export timing: a backend that does implement it owns the interaction
+   * between its concurrent flushes and {@link shutdown}'s drain (the OTel
+   * backend removed its implementation for exactly that hazard — see the
+   * revival Agent Note).
+   */
+  flush?(): void
+  /**
+   * Forward the fiber's disposal to the SDK: flush whatever is queued and
+   * reach quiescence, per the SDK's own shutdown contract. Everything
+   * emitted before this call must still be delivered — including records
+   * enqueued while a {@link flush} hint is in flight, so a backend whose SDK
+   * guards against concurrent flushes orders behind the outstanding one (the
+   * coordinator emits its dispose-time `shutdown` markers immediately before
+   * calling this). Awaited by the coordinator's dispose; a rejection is
+   * logged as a warning and never fails application teardown.
+   * @returns resolves when the backend's pipeline has quiesced.
+   */
+  shutdown(): Promise<void>
+}
+
+/**
+ * The backend contract in its loadable form: one implementation per context —
+ * the cordis `Service` registration under the `telemetry` key throws on a
+ * duplicate, cordis' standard behavior. A backend composes a
+ * {@link TelemetryCoordinator} in its constructor to install the capture side.
+ */
+export abstract class Telemetry extends Service implements TelemetryBackend {
+  constructor(ctx: Context) {
+    super(ctx, 'telemetry')
+  }
+
+  /**
+   * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home.
+   * @param record - the logical record to report; owned by the backend after the call.
+   */
+  abstract emit(record: TelemetryRecord): void
+
+  /** See {@link TelemetryBackend.flush}. */
+  flush?(): void
+
+  /**
+   * See {@link TelemetryBackend.shutdown}.
+   * @returns resolves when the backend's pipeline has quiesced.
+   */
+  abstract shutdown(): Promise<void>
+}
+
+export { TelemetryCoordinator } from './coordinator.ts'

+ 32 - 0
packages/telemetry/session-telemetry/src/invariant.ts

@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry`.
+ * @module @deepseek-ai/dsh-session-telemetry/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry'
+
+/** Cordis companion plugin name. */
+export const name = 'session-telemetry-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the seam's whole output is the backend handoff — a
+ * synchronous `emit()` call outside every authoritative event stream — and its
+ * capture side never appends session events, so no event/data relation exists
+ * for an independent companion to observe.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 116 - 0
packages/telemetry/session-telemetry/tests/redact.spec.ts

@@ -0,0 +1,116 @@
+/**
+ * The `telemetry/record` waterfall contract: pass-through when no listener is
+ * mounted, listener stacking and replacement, ops-record coverage, the
+ * untouched canonical log, and the fail-closed containment of a throwing rule.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import {
+  TelemetryCoordinator,
+  type TelemetryBackend,
+  type TelemetryRecord,
+} from '../src/index.ts'
+
+const FIXTURE_SECRET = 'sk-fixture1234567890'
+
+class CollectingBackend implements TelemetryBackend {
+  records: TelemetryRecord[] = []
+  emit(record: TelemetryRecord): void {
+    this.records.push(record)
+  }
+  async shutdown(): Promise<void> {}
+}
+
+async function setup() {
+  const backend = new CollectingBackend()
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  const fiber = await ctx.plugin({
+    name: 'fake-telemetry',
+    inject: ['sessions'],
+    apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+  })
+  return { ctx, backend, fiber }
+}
+
+describe('telemetry/record waterfall', () => {
+  it('passes records through unchanged when no listener is mounted', async () => {
+    const { ctx, backend } = await setup()
+    const session = ctx.sessions.create(SessionId('w'))
+    session.append('user/message', { content: [{ type: 'text', text: `key ${FIXTURE_SECRET}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    const body = backend.records[0]!.body as { content: { text: string }[] }
+    expect(body.content[0]!.text).toBe(`key ${FIXTURE_SECRET}`)
+  })
+
+  it('applies a mounted rule to every outbound record, ops records included', async () => {
+    const { ctx, backend, fiber } = await setup()
+    ctx.on('telemetry/record', (_record, next) => {
+      const record = next()
+      return { ...record, body: { scrubbed: true } }
+    })
+    const session = ctx.sessions.create(SessionId('rule'))
+    session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    expect(backend.records[0]!.body).toEqual({ scrubbed: true })
+    // The dispose-time shutdown ops record passes through the same waterfall.
+    await fiber.dispose()
+    const ops = backend.records.filter(record => record.channel === 'ops')
+    expect(ops).toHaveLength(1)
+    expect(ops[0]!.body).toEqual({ scrubbed: true })
+  })
+
+  it('keeps the canonical log untouched by a mounted rule', async () => {
+    const { ctx } = await setup()
+    ctx.on('telemetry/record', (_record, next) => ({ ...next(), body: null }))
+    const session = ctx.sessions.create(SessionId('log'))
+    session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    const logged = session.events[0]!.data as { content: { text: string }[] }
+    expect(logged.content[0]!.text).toBe(FIXTURE_SECRET)
+  })
+
+  it('stacks listeners outermost-first around next()', async () => {
+    const { ctx, backend } = await setup()
+    const order: string[] = []
+    ctx.on('telemetry/record', (_record, next) => {
+      order.push('outer-before')
+      const record = next()
+      order.push('outer-after')
+      return { ...record, attributes: { ...record.attributes, outer: 1 } }
+    })
+    ctx.on('telemetry/record', (_record, next) => {
+      order.push('inner')
+      const record = next()
+      return { ...record, attributes: { ...record.attributes, inner: 1 } }
+    })
+    const session = ctx.sessions.create(SessionId('stack'))
+    session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    expect(order).toEqual(['outer-before', 'inner', 'outer-after'])
+    expect(backend.records[0]!.attributes).toMatchObject({ outer: 1, inner: 1 })
+  })
+
+  it('a listener that skips next() replaces everything beneath it', async () => {
+    const { ctx, backend } = await setup()
+    const inner = { called: false }
+    ctx.on('telemetry/record', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord))
+    ctx.on('telemetry/record', (_record, next) => {
+      inner.called = true
+      return next()
+    })
+    const session = ctx.sessions.create(SessionId('veto'))
+    session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    expect(backend.records[0]!.body).toBe('replaced')
+    expect(inner.called).toBe(false)
+  })
+
+  it('a throwing rule withholds the record fail-closed without disturbing the log', async () => {
+    const { ctx, backend } = await setup()
+    ctx.on('telemetry/record', () => {
+      throw new Error('rule exploded')
+    })
+    const session = ctx.sessions.create(SessionId('closed'))
+    session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    expect(backend.records).toHaveLength(0)
+    expect(session.events).toHaveLength(1)
+  })
+})

+ 424 - 0
packages/telemetry/session-telemetry/tests/telemetry.spec.ts

@@ -0,0 +1,424 @@
+/**
+ * Coordinator semantics against a bare fake backend — the RFC's named unit
+ * tier for the seam: adoption (fresh, seeded, re-adoption via the handoff
+ * cursor), the fixed chunk projection, deep-copy isolation, turn-latency and
+ * dispose-ordering pins, failure containment, and the `agent/error` relay.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts'
+
+declare module '@deepseek-ai/dsh-session' {
+  interface SessionEventMap {
+    /**
+     * Test-only merged event proving unknown types flow through unchanged.
+     * @mode emit
+     * @param payload - opaque test payload
+     */
+    'telemetry-test/opaque': { payload: { nested: string[] } }
+  }
+}
+
+class FakeBackend implements TelemetryBackend {
+  records: TelemetryRecord[] = []
+  calls: string[] = []
+  emitError: Error | undefined
+  rejectSeq: number | undefined
+  shutdownError: Error | undefined
+  shutdownResolved = false
+
+  emit(record: TelemetryRecord): void {
+    if (this.emitError) throw this.emitError
+    if (this.rejectSeq !== undefined && record.attributes['event.seq'] === this.rejectSeq) {
+      throw new Error(`backend rejected seq ${this.rejectSeq}`)
+    }
+    this.records.push(record)
+    this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`)
+  }
+
+  flush = vi.fn()
+
+  async shutdown(): Promise<void> {
+    this.calls.push('shutdown')
+    await new Promise(resolve => setTimeout(resolve, 5))
+    if (this.shutdownError) throw this.shutdownError
+    this.shutdownResolved = true
+  }
+
+  ledger(): TelemetryRecord[] {
+    return this.records.filter(r => r.channel === 'ledger')
+  }
+}
+
+async function setup(backend: FakeBackend = new FakeBackend()) {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  const fiber = await ctx.plugin({
+    name: 'fake-telemetry',
+    inject: ['sessions'],
+    apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+  })
+  return { ctx, backend, fiber }
+}
+
+function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session {
+  return ctx.sessions.create(SessionId(id), { meta: {} })
+}
+
+function appendTurn(session: Session): void {
+  session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+  session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+}
+
+describe('TelemetryCoordinator capture', () => {
+  it('hands every appended event over with envelope identity and cloned body', async () => {
+    const { ctx, backend } = await setup()
+    const session = liveSession(ctx, 'cap')
+    appendTurn(session)
+
+    const start = backend.ledger()[0]!
+    const message = backend.ledger()[1]!
+    expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 })
+    expect(start.time).toBe(session.events[0]!.time)
+    expect(start.severity).toBe('info')
+    expect(message.attributes['event.seq']).toBe(1)
+    // Deep-copy isolation: mutating the handed-off body never reaches the log.
+    ;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered'
+    const logged = session.events[1] as SessionEvent<'user/message'>
+    expect(logged.data.content[0]).toMatchObject({ text: 'hello' })
+  })
+
+  it('stamps header facts on every record when present', async () => {
+    const { ctx, backend } = await setup()
+    const parent = SessionId('parent')
+    const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/tmp/proj', parentSession: parent } })
+    appendTurn(session)
+    for (const record of backend.ledger()) {
+      expect(record.attributes['session.cwd']).toBe('/tmp/proj')
+      expect(record.attributes['session.parent_id']).toBe('parent')
+    }
+  })
+
+  it('maps outcome flags to severity, unknown types falling through as info', async () => {
+    const { ctx, backend } = await setup()
+    const session = liveSession(ctx)
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' })
+    session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' })
+    session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' })
+    session.append('telemetry-test/opaque', { payload: { nested: [] } })
+    session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
+    const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity])
+    expect(severities).toEqual([
+      ['turn/start', 'info'],
+      ['tool/result', 'error'],
+      ['tool/result', 'info'],
+      ['prompt/blocked', 'warn'],
+      ['telemetry-test/opaque', 'info'],
+      ['turn/end', 'error'],
+    ])
+  })
+
+  it('passes unknown merged event types through unchanged', async () => {
+    const { ctx, backend } = await setup()
+    const session = liveSession(ctx)
+    session.append('telemetry-test/opaque', { payload: { nested: ['a', 'b'] } })
+    const record = backend.ledger()[0]!
+    expect(record.attributes['event.type']).toBe('telemetry-test/opaque')
+    expect(record.severity).toBe('info')
+    expect(record.body).toEqual({ payload: { nested: ['a', 'b'] } })
+  })
+
+  it('ships only the first chunk of each (turn, step), per session', async () => {
+    const { ctx, backend } = await setup()
+    const a = liveSession(ctx, 'a')
+    const b = liveSession(ctx, 'b')
+    const chunk = (s: Session, turn: number, step: number, text: string) =>
+      s.append('assistant/chunk', { turn, step, chunk: { type: 'text-delta', index: 0, text } })
+    chunk(a, 1, 1, 'a11-first')
+    chunk(a, 1, 1, 'a11-second')
+    chunk(a, 1, 2, 'a12-first')
+    chunk(b, 1, 1, 'b11-first')
+    chunk(b, 1, 1, 'b11-second')
+    const shipped = backend.ledger().map(r => [r.attributes['session.id'], (r.body as { chunk: { text: string } }).chunk.text])
+    expect(shipped).toEqual([
+      ['a', 'a11-first'],
+      ['a', 'a12-first'],
+      ['b', 'b11-first'],
+    ])
+  })
+})
+
+describe('TelemetryCoordinator adoption', () => {
+  it('starts export at the construction boundary: seeded history never re-exports', async () => {
+    const backend = new FakeBackend()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const parent = liveSession(ctx, 'seed-parent')
+    appendTurn(parent)
+    const child = ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} })
+    await ctx.plugin({
+      name: 'fake-telemetry',
+      inject: ['sessions'],
+      apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+    })
+    // The live parent (no constructor seed) replays in full; the child's
+    // inherited prefix already left the process under another identity (the
+    // parent's id here; the same id in a previous process for a resume) and
+    // must not be re-exported — only its live suffix ships.
+    const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
+    expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
+    expect(seqs.filter(([id]) => id === 'seeded')).toEqual([])
+    child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    expect(backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]))
+      .toEqual(expect.arrayContaining([['seeded', 2]]))
+  })
+
+  it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => {
+    const backend = new FakeBackend()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const donor = ctx.sessions.create(SessionId('donor'), { meta: {} })
+    donor.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } })
+    const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} })
+    await ctx.plugin({
+      name: 'fake-telemetry',
+      inject: ['sessions'],
+      apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+    })
+    const ofResumed = () => backend.ledger()
+      .filter(r => r.attributes['session.id'] === 'resumed')
+      .map(r => r.attributes['event.seq'])
+    expect(ofResumed()).toEqual([])
+    // The seed fed the projection: the (turn 1, step 1) first chunk already
+    // shipped from the original process, so its continuation is re-dropped…
+    resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } })
+    expect(ofResumed()).toEqual([])
+    // …while a new step's first chunk exports normally.
+    resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } })
+    expect(ofResumed()).toEqual([3])
+  })
+
+  it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => {
+    const backend = new FakeBackend()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const parent = liveSession(ctx, 'stitch-parent')
+    appendTurn(parent)
+    const child = ctx.sessions.create(SessionId('stitch-child'), {
+      seed: [...parent.events],
+      meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 },
+    })
+    await ctx.plugin({
+      name: 'fake-telemetry',
+      inject: ['sessions'],
+      apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+    })
+    child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')!
+    expect(record.attributes['session.parent_id']).toBe('stitch-parent')
+    expect(record.attributes['session.seed_length']).toBe(2)
+  })
+
+  it('adopts exactly once when created fires after the sweep', async () => {
+    const backend = new FakeBackend()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    // The enter/announce window: prepare+enter puts the session in the store
+    // (visible to the constructor sweep) before `session/created` fires, so a
+    // coordinator loaded inside that window sees the session twice — sweep
+    // first, created second. The second adoption must be a no-op.
+    const session = ctx.sessions.prepare(SessionId('overlap'))
+    appendTurn(session)
+    ctx.sessions.enter(session)
+    await ctx.plugin({
+      name: 'fake-telemetry',
+      inject: ['sessions'],
+      apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+    })
+    expect(backend.ledger()).toHaveLength(2)
+    ctx.sessions.announce(session)
+    expect(backend.ledger()).toHaveLength(2)
+  })
+
+  it('resumes from the handoff cursor across a reload, re-dropping mid-step chunks', async () => {
+    const backend = new FakeBackend()
+    const { ctx, fiber } = await setup(backend)
+    const session = liveSession(ctx, 'hmr')
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } })
+    expect(backend.ledger()).toHaveLength(2)
+
+    await fiber.dispose()
+    // The reload window: appends while no telemetry listener is registered.
+    session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } })
+    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+
+    const second = new FakeBackend()
+    await ctx.plugin({
+      name: 'fake-telemetry-2',
+      inject: ['sessions'],
+      apply: (inner: Context) => void new TelemetryCoordinator(inner, second),
+    })
+    // Only the window events past the cursor are re-handed, and the mid-step
+    // continuation is re-dropped because ≤cursor events rebuilt the projection.
+    expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
+  })
+
+  it('replays past a record the backend rejects: one event withheld, the rest adopted', async () => {
+    const backend = new FakeBackend()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    const session = liveSession(ctx, 'partial')
+    appendTurn(session)
+    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    // The backend rejects exactly the middle historical event: fail-closed
+    // must withhold THAT record only — an adoption replay that dies on the
+    // first contained failure would silently skip the rest of the log while
+    // the session stays marked adopted.
+    backend.rejectSeq = 1
+    await ctx.plugin({
+      name: 'fake-telemetry',
+      inject: ['sessions'],
+      apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+    })
+    expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 2])
+    expect(warn).toHaveBeenCalled()
+  })
+
+  it('re-hands the full log when no cursor survived (fresh session object)', async () => {
+    const backend = new FakeBackend()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const session = liveSession(ctx, 'fresh')
+    appendTurn(session)
+    await ctx.plugin({
+      name: 'fake-telemetry',
+      inject: ['sessions'],
+      apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+    })
+    expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 1])
+  })
+})
+
+describe('TelemetryCoordinator lifecycle and containment', () => {
+  it('forwards session/flush as a hint without awaiting backend work', async () => {
+    const { ctx, backend } = await setup()
+    const session = liveSession(ctx)
+    let settled = false
+    backend.flush.mockImplementation(() => {
+      // The backend may kick off arbitrary async work; the loop's parallel must not wait for it.
+      void new Promise(resolve => setTimeout(resolve, 50)).then(() => { settled = true })
+    })
+    await ctx.parallel('session/flush', session)
+    expect(backend.flush).toHaveBeenCalledTimes(1)
+    expect(settled).toBe(false)
+  })
+
+  it('ignores flush hints for sessions it never adopted', async () => {
+    const { ctx, backend } = await setup()
+    const stranger = ctx.sessions.prepare(SessionId('stranger'), { meta: {} })
+    await ctx.parallel('session/flush', stranger)
+    expect(backend.flush).not.toHaveBeenCalled()
+  })
+
+  it('emits no marker for a session whose announcement was vetoed before adoption', async () => {
+    const backend = new FakeBackend()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    // A listener registered BEFORE the coordinator vetoes publication: the
+    // store still emits the paired `session/disposed` for rollback, but the
+    // coordinator never saw `session/created` — a marker for a session the
+    // receiver saw no activity from would be noise, not signal.
+    ctx.on('session/created', () => {
+      throw new Error('vetoed by an earlier listener')
+    })
+    await ctx.plugin({
+      name: 'fake-telemetry',
+      inject: ['sessions'],
+      apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+    })
+    expect(() => ctx.sessions.create(SessionId('vetoed'), { meta: {} })).toThrow('vetoed')
+    expect(backend.records.filter(r => r.channel === 'ops')).toHaveLength(0)
+  })
+
+  it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => {
+    const { ctx, backend, fiber } = await setup()
+    liveSession(ctx, 's1')
+    liveSession(ctx, 's2')
+    await fiber.dispose()
+    expect(backend.calls).toEqual(['emit:shutdown', 'emit:shutdown', 'shutdown'])
+    expect(backend.shutdownResolved).toBe(true)
+    const ops = backend.records.filter(r => r.channel === 'ops')
+    expect(ops.map(r => r.attributes['session.id']).sort()).toEqual(['s1', 's2'])
+    expect(ops.every(r => r.attributes['telemetry.op'] === 'shutdown' && r.severity === 'info')).toBe(true)
+    expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true)
+  })
+
+  it('emits the shutdown marker at the session’s own disposal edge, then retires it', async () => {
+    const { ctx, backend, fiber } = await setup()
+    liveSession(ctx, 'survivor')
+    // A session owned by its own fiber: disposing the fiber detaches it from
+    // the store and emits `session/disposed` — the authoritative termination
+    // edge. The marker must ride THAT edge (receivers classify a session with
+    // activity and no marker as crashed, so a normally closed session in a
+    // long-running host must not look like a crash), and the session retires
+    // from the adopted set so unload neither retains it nor re-marks it.
+    const owner = await ctx.plugin(Object.assign((inner: Context) => {
+      inner.sessions.create(SessionId('ephemeral'), { meta: {} })
+    }, { inject: ['sessions'] }))
+    await owner.dispose()
+    const atEdge = backend.records.filter(r => r.channel === 'ops')
+    expect(atEdge.map(r => r.attributes['session.id'])).toEqual(['ephemeral'])
+    expect(atEdge[0]!.attributes['telemetry.op']).toBe('shutdown')
+    await fiber.dispose()
+    const ops = backend.records.filter(r => r.channel === 'ops')
+    expect(ops.map(r => r.attributes['session.id'])).toEqual(['ephemeral', 'survivor'])
+  })
+
+  it('warns instead of throwing when backend shutdown fails', async () => {
+    const backend = new FakeBackend()
+    backend.shutdownError = new Error('exporter unreachable')
+    const { ctx, fiber } = await setup(backend)
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    liveSession(ctx)
+    await expect(fiber.dispose()).resolves.not.toThrow()
+    expect(warn.mock.calls.some(args => String(args[0]).includes('shutdown failed'))).toBe(true)
+  })
+
+  it('contains emit failures: the append succeeds and capture heals', async () => {
+    const { ctx, backend } = await setup()
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    const session = liveSession(ctx)
+    backend.emitError = new Error('backend broke')
+    expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow()
+    expect(warn).toHaveBeenCalled()
+    backend.emitError = undefined
+    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
+  })
+
+  it('relays agent/error as an ops record with identity and structured name', async () => {
+    const { ctx, backend } = await setup()
+    const session = liveSession(ctx, 'erring')
+    // Only the members the relay reads; the full Agent surface is irrelevant here.
+    const agent = { id: 'agent-1', session } as Agent
+    ctx.emit('agent/error', agent, 3, 2, new TypeError('adapter exploded'))
+    const record = backend.records.find(r => r.channel === 'ops')!
+    expect(record.severity).toBe('error')
+    expect(record.attributes).toMatchObject({
+      'telemetry.op': 'agent-error',
+      'session.id': 'erring',
+      'agent.id': 'agent-1',
+      'error.name': 'TypeError',
+      turn: 3,
+      step: 2,
+    })
+    expect(record.body).toEqual({ name: 'TypeError', message: 'adapter exploded' })
+  })
+})

+ 27 - 0
packages/telemetry/session-telemetry/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 203 - 0
pnpm-lock.yaml

@@ -493,6 +493,9 @@ importers:
       '@deepseek-ai/dsh-session-query-sqlite':
         specifier: workspace:*
         version: link:../packages/session-query/session-query-sqlite
+      '@deepseek-ai/dsh-session-telemetry-otel':
+        specifier: workspace:*
+        version: link:../packages/telemetry/session-telemetry-otel
       '@deepseek-ai/dsh-session-title-first-message-llm':
         specifier: workspace:*
         version: link:../packages/session-title/session-title-first-message-llm
@@ -4170,6 +4173,64 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/telemetry/session-telemetry:
+    devDependencies:
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
+  packages/telemetry/session-telemetry-otel:
+    dependencies:
+      '@opentelemetry/api':
+        specifier: ^1.9.1
+        version: 1.9.1
+      '@opentelemetry/api-logs':
+        specifier: ^0.220.0
+        version: 0.220.0
+      '@opentelemetry/exporter-logs-otlp-http':
+        specifier: ^0.220.0
+        version: 0.220.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/otlp-exporter-base':
+        specifier: ^0.220.0
+        version: 0.220.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/resources':
+        specifier: ^2.9.0
+        version: 2.10.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/sdk-logs':
+        specifier: ^0.220.0
+        version: 0.220.0(@opentelemetry/api@1.9.1)
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-telemetry':
+        specifier: workspace:^
+        version: link:../session-telemetry
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
+
   packages/timeout/timeout-policy:
     devDependencies:
       '@deepseek-ai/dsh-invariants':
@@ -6384,10 +6445,78 @@ packages:
   '@nodable/entities@2.2.0':
     resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==}
 
+  '@opentelemetry/api-logs@0.220.0':
+    resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==}
+    engines: {node: '>=8.0.0'}
+
   '@opentelemetry/api@1.9.0':
     resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
     engines: {node: '>=8.0.0'}
 
+  '@opentelemetry/api@1.9.1':
+    resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
+    engines: {node: '>=8.0.0'}
+
+  '@opentelemetry/core@2.10.0':
+    resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+  '@opentelemetry/core@2.9.0':
+    resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+  '@opentelemetry/exporter-logs-otlp-http@0.220.0':
+    resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': ^1.3.0
+
+  '@opentelemetry/otlp-exporter-base@0.220.0':
+    resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': ^1.3.0
+
+  '@opentelemetry/otlp-transformer@0.220.0':
+    resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': ^1.3.0
+
+  '@opentelemetry/resources@2.10.0':
+    resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+  '@opentelemetry/resources@2.9.0':
+    resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+  '@opentelemetry/sdk-logs@0.220.0':
+    resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': '>=1.4.0 <1.10.0'
+
+  '@opentelemetry/sdk-metrics@2.9.0':
+    resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': '>=1.9.0 <1.10.0'
+
+  '@opentelemetry/sdk-trace@2.9.0':
+    resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
   '@opentelemetry/semantic-conventions@1.43.0':
     resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
     engines: {node: '>=14'}
@@ -11410,8 +11539,82 @@ snapshots:
 
   '@nodable/entities@2.2.0': {}
 
+  '@opentelemetry/api-logs@0.220.0':
+    dependencies:
+      '@opentelemetry/api': 1.9.0
+
   '@opentelemetry/api@1.9.0': {}
 
+  '@opentelemetry/api@1.9.1': {}
+
+  '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/semantic-conventions': 1.43.0
+
+  '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/semantic-conventions': 1.43.0
+
+  '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/api-logs': 0.220.0
+      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1)
+
+  '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
+
+  '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/api-logs': 0.220.0
+      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1)
+
+  '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/semantic-conventions': 1.43.0
+
+  '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/semantic-conventions': 1.43.0
+
+  '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/api-logs': 0.220.0
+      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/semantic-conventions': 1.43.0
+
+  '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
+
+  '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/semantic-conventions': 1.43.0
+
   '@opentelemetry/semantic-conventions@1.43.0': {}
 
   '@oxc-parser/binding-android-arm-eabi@0.133.0':

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -247,6 +247,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
   SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
   SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
+  TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
   WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
   WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
   WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',

+ 10 - 0
scripts/gen-doc-graphs.ts

@@ -78,6 +78,7 @@ const GROUP_ORDER = [
   'session-persistence',
   'session-query',
   'session-title',
+  'telemetry',
   'storage',
   'workspace',
   'support',
@@ -136,6 +137,15 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
     note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
   },
+  {
+    key: 'telemetry',
+    pkg: 'session-telemetry',
+    title: 'Session telemetry seam',
+    mode: 'seam',
+    implementations: ['session-telemetry-otel'],
+    consumers: [],
+    note: 'The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process.',
+  },
   {
     key: 'storage',
     pkg: 'storage',

+ 2 - 0
scripts/verify-package-readme-model-experience.ts

@@ -87,6 +87,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
   'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
   'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
+  'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
+  'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
   'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
   'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
   'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },

+ 2 - 0
tsconfig.base.json

@@ -75,6 +75,7 @@
         "./packages/hooks/*/src/invariant.ts",
         "./packages/session-persistence/*/src/invariant.ts",
         "./packages/session-query/*/src/invariant.ts",
+        "./packages/telemetry/*/src/invariant.ts",
         "./packages/acp/*/src/invariant.ts",
         "./packages/storage/*/src/invariant.ts",
         "./packages/workspace/*/src/invariant.ts",
@@ -154,6 +155,7 @@
         "./packages/session-persistence/*/src",
         "./packages/session-query/*/src",
         "./packages/session-title/*/src",
+        "./packages/telemetry/*/src",
         "./packages/acp/*/src",
         "./packages/storage/*/src",
         "./packages/workspace/*/src",

+ 2 - 0
tsconfig.host.json

@@ -67,6 +67,8 @@
     { "path": "./packages/session-title/session-title-llm" },
     { "path": "./packages/session-title/session-title-first-message-llm" },
     { "path": "./packages/session-title/session-title-all-messages-llm" },
+    { "path": "./packages/telemetry/session-telemetry" },
+    { "path": "./packages/telemetry/session-telemetry-otel" },
     { "path": "./packages/core/system-prompt" },
     { "path": "./packages/core/agent" },
     { "path": "./packages/ui/commands" },

Some files were not shown because too many files changed in this diff