Skip to content

feat(agent): portable kernel extraction and service contracts (stages 0-2) - #2315

Draft
zerob13 wants to merge 75 commits into
devfrom
architecture/standalone-agent-harness
Draft

zerob13 wants to merge 75 commits into
devfrom
architecture/standalone-agent-harness

Conversation

@zerob13

@zerob13 zerob13 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

This branch delivers the repository and ownership groundwork for a portable DeepChat agent service. It is intentionally a structural extraction and contract-freeze PR for stages 0–2, not the standalone service product itself.

The current and future execution paths are deliberately separate:

Current
CLI local-control client ───────> Desktop-owned local-control server

Desktop composition
  ├─> built-in host adapters ──> @deepchat/agent-kernel ──> @deepchat/shared
  │                              └─ provider / MCP portable cores
  └─> direct ACP backend ──────> external ACP agent (owns its own loop)

Future — not implemented by this PR
CLI service-host composition ──> kernel + domain cores + headless host adapters
Desktop / CLI clients ────────> standalone service binding
Direct ACP remains a separate peer path

The branch preserves the current Desktop compatibility path. It does not add a standalone service process, a new transport or authentication implementation, a Desktop/CLI cutover, or a new UI. Those belong to later standalone-service stages.

Scope at a glance

  • 73 commits relative to origin/dev
  • 4,710 files changed: +76,070 / -25,187
  • Branch: architecture/standalone-agent-harness
  • PR target: dev
  • Current HEAD: dab4b3b15
  • Required toolchain: Node >=24.18.0 <25, pnpm >=10.34.5 <11

The large file count is primarily a deliberate monorepo relocation and ownership change: Desktop sources/tests/resources move under packages/desktop, while shared contracts, CLI, provider, MCP, and kernel code become explicit workspace package surfaces. This is not a product-feature diff of the same size.

What changed

1. Standalone architecture and contract freeze — Stages 0–1

Added the normative standalone-agent architecture, baseline, compatibility mapping, and execution tracker under:

  • docs/architecture/standalone-agent-harness/spec.md
  • docs/architecture/standalone-agent-harness/plan.md
  • docs/architecture/standalone-agent-harness/baseline.md
  • docs/architecture/standalone-agent-harness/compatibility.md

The client-facing contract is deliberately serializable and host-neutral. It covers the initial vocabulary for:

  • typed handshake and complete capability advertisement;
  • session snapshot and bounded event subscription;
  • epoch/sequence cursor replay and explicit resync behavior;
  • submission identity, duplicate detection, receipt lookup, and retention semantics;
  • interaction/approval request-response DTOs;
  • separate cancellation layers for queued submission, running run, and active turn;
  • owned artifact references;
  • bounded structured errors;
  • explicit capability_unavailable behavior and compatibility mapping to the maintained local-control CLI.

The public DTOs do not carry Electron objects, callbacks, AbortSignal, provider clients, database connections, principals, renderer handles, absolute paths, or runtime class instances. typecheck:contracts compiles the contract assertions so these boundaries are enforced rather than documented only.

The Stage 1 Agent Service DTO/client contracts currently remain under packages/desktop/src/shared/contracts/agent-service. Promoted local-control wire contracts live in packages/shared. Host-neutral contract design does not imply service wiring or publication through @deepchat/shared.

The current client contract is intentionally incomplete: live delivery, session lifecycle, steering, interruption, pending-input queue operations, and compatibility mapping are explicit hand-offs to later slices rather than claims that a final service API already exists. A missing or pruned receipt is also not evidence that execution never started; a binding that cannot retain receipts reports receipt_not_retained, and not_found does not authorize blind resubmission.

2. ACP ownership and lifecycle seams

The direct ACP backend is separated from the built-in Desktop agent ownership model:

  • ACP session state is accessed through a neutral SessionStatePort/AcpSessionStateAdapter instead of hydrating built-in scope.
  • ACP sessions own their status publication, transcript/skills routing, and destruction path.
  • The compatibility factory no longer makes ACP look like a built-in session internally.
  • Provider settings are sanitized before use and remain the update base.
  • ACP prompt cancellation uses the caller-owned active prompt signal all the way into projection/runtime helpers; no replacement AbortController is created.
  • closeAll() has bounded shutdown phases. An uncooperative peer cannot block shutdown forever; pending work is detached after the phase timeout and runtime maps are cleared.

The direct ACP backend continues to use its own AcpAgentRuntime. External ACP does not execute through the built-in @deepchat/agent-kernel loop. Existing Desktop and direct ACP flows retain their respective host seams; these changes close ownership and lifecycle leaks without merging the two runtimes.

3. Kernel extraction into @deepchat/agent-kernel

The portable built-in agent kernel is now a private workspace package:

  • package: packages/agent-kernel
  • package name: @deepchat/agent-kernel
  • explicit public subpath exports;
  • loop/runtime/memory/resources/instance/contracts/composition and neutral collaborators owned by the package;
  • generated JavaScript and declarations emitted with plain per-file TypeScript build;
  • forbidden-specifier scan prevents Electron, SQLite, PTY, repository aliases, and host paths from leaking into the artifact.

The old host re-export layer was removed rather than preserved as an invisible public API. Production callers now use supported package exports. The kernel depends on explicit structural ports instead of Pick<HostClass> or Desktop implementation types.

The package is embedded by Desktop through the workspace link, so the current Desktop path still runs the same kernel implementation. A future service host can consume the package without importing Electron, but that host composition is not implemented by this PR.

4. Shared package extraction — @deepchat/shared

The reviewed neutral closure is now owned by packages/shared:

  • package name: @deepchat/shared;
  • exact exported subpaths with manifest-derived aliases;
  • shared schemas, value modules, event contracts, route contracts, model/provider types, and portable utilities;
  • browser-safe closure for promoted modules;
  • no shared → kernel/domain/Desktop dependency;
  • compiler-symbol identity regression prevents accidental any/unknown substitutions, including the MessageFile barrel case.

The package boundary is based on actual consumers, not a speculative extraction of all historical src/shared files.

5. Desktop relocation into a normal workspace package

Desktop is now a workspace member at packages/desktop while retaining its product identity (name: DeepChat):

  • Desktop source, tests, resources, build output, Electron/Vite/Builder configuration and app-local scripts move together;
  • repository root becomes a private workspace/tooling package;
  • root lockfile and pnpm policy remain authoritative;
  • config-relative paths replace caller-cwd assumptions;
  • Desktop outputs remain app-local (packages/desktop/out/{main,preload,renderer,cli}) and Builder staging remains under Desktop;
  • installed artifact layout and app identity are preserved;
  • root and filtered commands use the same configuration/resources/output contract.

No UI behavior change is intended. Desktop still owns real application composition, ACP lifecycle, built-in host adapters, local-control server behavior, and the Desktop-owned Agent Service contract gate. The relocation is therefore not only a path/config move; those runtime boundaries remain part of the behavior review.

6. CLI package and launcher boundaries

Added packages/cli as the explicit CLI client/launcher package boundary:

  • exported launcher artifacts and global --help/--version behavior;
  • explicit Desktop version input rather than incidental root-manifest discovery;
  • package-local build and declaration boundaries;
  • installed launchers require the bundled Electron runtime and fail closed without a system-Node fallback;
  • current local-control protocol remains mapped and compatibility-preserved.

The current CLI bundle itself can be executed with system Node during development after it has been built. The installed-launcher runtime-discovery rule must not be confused with a general Electron-only restriction on the bundle:

The current local development loop is deliberately explicit:

  • pnpm run cli:build bundles packages/desktop/out/cli/deepchat.mjs, using the Desktop version;
  • pnpm run cli:run -- --help and pnpm run cli:run -- profile list execute that bundle with plain Node and forward its arguments;
  • packages/desktop/scripts/run-cli.mjs fails fast when the bundle is missing, strips the nested -- forwarding marker, and propagates the child exit code;
  • system Node can execute the development bundle, but local-control operations still require a running Desktop with its local control plane available.

The preceding watch-mode implementation was replaced by this build-then-run loop in the latest commit. This branch does not turn the CLI into the standalone service. Stage 3 service-host composition, profile ownership, headless credential/OAuth ownership, and Desktop/CLI cutover remain future work.

7. Provider and MCP portable cores

Extracted provider and MCP runtime code behind explicit ports:

  • provider runtime receives config, credentials, OAuth, and related host capabilities through ports;
  • standalone completion now fails fast by default instead of converting provider/auth/network failures into an indistinguishable empty string;
  • swallowing is explicit opt-in and request diagnostics contain provider/model metadata rather than prompt contents or raw request messages;
  • MCP transport/session/process code is isolated behind host capabilities and bounded stdio/process behavior;
  • MCP package is explicitly Node-only and has a real isolated stdio/HTTP artifact-consumer gate;
  • regular root build now includes MCP in the topological build chain.

MCP diagnostics are intentionally split by boundary:

  • default logs, renderer-facing diagnostics state, and failed status events carry a fixed error category;
  • raw errors still propagate to callers and protocol responses by contract, so callers can decide their own handling.

8. Build, test, CI, and artifact gates

Added or hardened the package and repository gates:

  • manifest-derived package/source aliases;
  • cold source typechecks for provider and MCP;
  • shared → provider/MCP → kernel/consumer build ordering;
  • serialized artifact writers to avoid shared dist races;
  • isolated declaration/runtime consumer checks;
  • real staged-artifact MCP consumers over both stdio and HTTP;
  • kernel port structure type gate;
  • agent-service contract type gate;
  • forbidden-import and declaration-closure checks;
  • package-impact, package-size, release-preflight, native path, and relocated CI checks;
  • config-relative plugin bundle/package paths and app-root clean action.

These gates are intended to prove both sides of each package boundary:

  1. clean source development/typecheck without relying on stale build output;
  2. isolated declaration/runtime consumption from staged artifacts without repository aliases or workspace hoisting assumptions.

typecheck:contracts is static boundary evidence. It does not by itself prove that a service transport, authentication layer, or standalone host has been implemented.

9. Provider/ACP registry refresh and provenance

The branch retains the generated provider and ACP registry refresh required by the normal build flow.

The provider refresh was audited against:

  • upstream: ThinkInAIXYZ/PublicProviderConf
  • branch: dev
  • immutable audit commit: e67c827216525474...

The ten model removals from the carried refresh were independently verified as absent from upstream at that commit. The sanitizer now preserves a future upstream updated_at as source_updated_at so subsequent generated snapshots can carry source provenance.

The current checked-in providers.json predates that metadata change and does not yet contain source_updated_at. This is a non-blocking P2 follow-up for the next registry refresh, not a runtime compatibility issue.

Runtime behavior: before and after

Current Desktop built-in path

Before: Desktop -> root-owned src/main/src/shared implementation graph
After:  Desktop composition -> Desktop host adapters
                              -> @deepchat/agent-kernel
                              -> @deepchat/shared
  • Same Desktop product identity and installed layout.
  • Same main/preload/renderer ownership.
  • No new service process.
  • No new UI.
  • No migration of credentials, database schema, profile layout, or encryption keys.

Direct ACP path

Before/after: Desktop direct ACP backend -> AcpAgentRuntime -> external ACP agent

The direct ACP runtime remains a separate peer path; it is not routed through the built-in kernel loop.

ACP cancellation

Before: active prompt signal -> compatibility projection creates a different signal
After:  active prompt signal -> ACP projection -> runtime helpers observe the same signal

Cancellation, timeout, and peer-exit now reach the projection that owns the work.

Provider failure

Before: provider/auth/network failure -> empty completion string by default
After:  provider/auth/network failure -> propagated error by default
        explicit swallowErrors:true -> intentional fallback, redacted diagnostics

MCP diagnostics

Remote/local Error
      ├── renderer-facing diagnostics state -> error category only
      ├── failed status event             -> error category only
      └── default console diagnostics    -> operation/server/category only

Caller/protocol response -> existing propagation semantics remain unchanged

Shutdown

Before: closeAll waits indefinitely for an unresponsive ACP peer
After:  bounded phase timeout -> detach uncooperative work -> clear runtime maps

What this PR does not do

These are intentionally outside this branch:

  • no standalone service process or daemon;
  • no service-host composition in CLI;
  • no new transport/authentication implementation;
  • no Desktop/CLI cutover;
  • no headless credential/OAuth/profile owner or safeStorage compatibility strategy;
  • no TUI, scrollback UI, interactive shell, or Desktop-dependent plugin support;
  • no credential-format, database-schema, profile-layout, or encryption-key migration;
  • no new UI/product feature.

The structural implementations and contract slices are present, with local and independently reviewed evidence recorded below. This does not close all monorepo acceptance gates: applicable cross-platform packaging/native and launcher checks remain pending or environment-dependent. Standalone Stages 3–7 are separately unimplemented. In particular, the recorded final package evidence is unsigned macOS arm64; non-macOS targets, DMG/ZIP publication, signing, and update flows are not proven by that evidence.

Verification

Verification evidence is revision-scoped. The latest two commits are included in the scope statistics above:

  • ced528c5b (feat(cli): add a watch-mode dev build), subsequently superseded by
  • dab4b3b15 (feat(cli): add a local run command).

The targeted checks below were run on the previously reviewed revision and were not implicitly re-run for every path after these CLI workflow commits.

Previously reviewed revision evidence

On the reviewed pre-watch revision, using Node v24.18.0 / pnpm v10.34.5:

  • pnpm run typecheck
    • shared, provider, MCP, CLI, Desktop node/web/contracts/kernel-port gates;
  • pnpm run build:mcp;
  • pnpm run test:mcp:artifact
    • isolated declaration/runtime gate plus real stdio and HTTP transport fixtures;
  • targeted MCP/Desktop tests: 79 passed;
  • MCP runtime tests: 3 passed;
  • pnpm run format:check;
  • pnpm run i18n:validate;
  • pnpm run lint — 0 errors, existing warnings only;
  • git diff --check — clean for that reviewed revision.

Recorded whole-branch / package evidence

The architecture plans also record broader evidence from the migration work, including main-process and renderer suites, the root build, and a specified unsigned macOS arm64 packaged smoke. Those records include 658 main files / 9,252 tests and 278 renderer files / 2,516 tests. They are historical, path-scoped evidence and should not be read as a claim that all of those checks were re-run at ced528c5b.

The two latest CLI workflow commits have not been claimed as independently revalidated by the targeted checks above. The documented development behavior on the current HEAD is:

pnpm run cli:build
pnpm run cli:run -- --help
pnpm run cli:run -- profile list

The per-stage acceptance evidence, exact command history, known limitations, and future blockers remain recorded in:

  • docs/architecture/standalone-agent-harness/plan.md
  • docs/architecture/monorepo-migration/plan.md
  • docs/architecture/standalone-agent-harness/baseline.md
  • docs/architecture/standalone-agent-harness/compatibility.md

Reviewer reading order

  1. Start with docs/architecture/standalone-agent-harness/spec.md (especially the two current execution paths and the prohibition on merging direct ACP into the built-in loop).
  2. Read docs/architecture/monorepo-migration/spec.md for package ownership, dependency, and compatibility constraints.
  3. Inspect the current entrypoints and actual contract locations: Desktop composition, the direct ACP backend, packages/cli/package.json, packages/desktop/src/shared/contracts/agent-service, and the promoted packages/shared local-control contracts.
  4. Review packages/shared/package.json, packages/agent-kernel/package.json, packages/provider/package.json, packages/mcp/package.json, and packages/cli/package.json for package boundaries.
  5. Review the kernel structural-port and agent-service contract gates before reading relocation noise.
  6. Inspect packages/desktop for path/config/output ownership and the retained composition, ACP lifecycle, host adapters, local-control server, and contract behavior.
  7. Inspect provider/MCP behavior changes separately from the mechanical moves.
  8. Use the latest evidence and exit-gate notes in both plans instead of treating the 4,710-file relocation as one undifferentiated runtime change.

Review status

This PR has completed the intended structural extraction and contract slices for stages 0–2, with the boundaries and known observations recorded above. It should not be read as claiming that the standalone service host, all cross-platform packaging/native gates, or the final Desktop/CLI cutover already exist. The remaining standalone host work and any unclosed monorepo exit gates are explicitly tracked as separate follow-up work.

Capability and error DTOs could be accepted with weaker meaning than the
stage 1A contract claims:

- AgentServiceCapabilitiesSchema required neither a complete nor a
  non-empty set, so an omitted Desktop-only capability was
  indistinguishable from a supported one. Every declared id is now
  required exactly once while unknown, duplicate, and over-limit sets stay
  rejected.
- capability_unavailable did not require the capability, and any code
  could carry it. The code now owns the structured fields:
  capability_unavailable requires capability plus requiredClient
  (nullable, as in the capability DTO), and every other code rejects both,
  so a client never falls back to message.
- The wire envelope inherited AppError's unbounded message and details.
  message is now non-empty and capped at 4096, and details is bounded in
  key count, key shape, key length, value depth, and value size, with
  credential-, path-, handle-, and identity-shaped keys refused.

The mapping from service error codes onto LOCAL_CONTROL_ERROR_CODES is
explicit in this file and total by construction; localControl.ts is
unchanged.
- Measure the detail value budget on the value's own JSON UTF-8 encoding, so a
  nested key or array element can no longer hide bytes from the size limit, and
  run the depth walk plus the encoding check ahead of `JsonValueSchema` so an
  unmeasurable or pathologically deep value fails closed instead of escaping as
  a RangeError from the recursive JSON walk.
- Drop the forbidden detail-key fragment and path blacklists. They rejected
  valid diagnostics such as `tokenCount` and `maxTokens` and promised a
  redaction no schema can perform; key syntax, key length, key count, and the
  depth/byte budgets stay, and the comment now states that sensitive-data
  redaction is the producer's responsibility.
- State in the local-control mapping comment that `capability_unavailable` and
  `service_unavailable` both collapse onto `unavailable`, so the capability
  identity must be preserved by the adapter instead of read back from the code.
- Extend the contract suite with the nested-key, large-array, boundary,
  fail-closed, and diagnostic-field cases.
An accessor- or proxy-bearing in-process value could be read more than once: the
depth and size pre-checks measured one reading while the piped `JsonValueSchema`
validated another, so a value whose later reads grew past the budget was accepted,
and a getter or proxy trap that threw on a later read escaped `safeParse` as an
uncaught exception.

- Read each detail value once, through property descriptors, into a fresh plain
  JSON copy, and measure the size budget on that copy, so the bytes that were
  budgeted are the bytes a caller receives.
- Reject whatever is not plain JSON data: accessors, array holes, non-Object.prototype
  prototypes, symbol keys, functions, bigints, undefined, and non-finite numbers, and
  turn any throwing read or measurement into a schema issue instead of an exception.
- Extend the contract suite with the changing accessor, throwing accessor, throwing
  proxy, and non-plain object cases, plus the multi-byte UTF-8 size boundary.
Stage 0 record for the standalone agent service, at checked revision
7e758ab (plus the 4c07c5b architecture baseline and the four Stage 1A
contract commits).

baseline.md freezes the Desktop and direct ACP flows, the identifier and
ownership matrices, the capability classification with the first-version
allowlist, resource ownership through shutdown, the portable-import probe
evidence, the invariants, and the Stage 1 handoff.

Two blockers are recorded instead of claimed as passes:

- The minimum two-turn/tool-continuation scenario was not completed: Node
  22.22.0 is outside the declared >=24.18.0 <25, the installed Electron
  41.10.4 does not match the lockfile 43.6.0, and there is no headless
  composition root to run it against.
- safeStorage is unavailable outside a full Electron runtime and is imported
  at module scope by the credential store, so credential access blocks
  Stage 3.

plan.md Stage 0 checkboxes are flipped per evidence, the unproven scenario
is satisfied only through its blocker branch, and the reviewed-inventory
line stays unchecked pending review. A Stage 1 sub-slice rule records 1A
DTO-only, 1B events/interaction/cancellation, 1C client adapters, and 1D
compatibility mapping so the delegation boundary lives in the repository.

Documentation only: no production code, test, or configuration change.
Adds the Stage 1B client-facing DTOs and nothing else: the typed event
envelope, bounded event subscription with epoch:seq cursor replay, overflow
and resync semantics, the authoritative snapshot, owned artifact references,
interaction/approval request-response DTOs, and three separate cancellation
layers.

Events reuse LocalControlEventCursorSchema and the Stage 1A ids. Every
object is strict and every union discriminated, so unknown fields, wrong
discriminators, missing cursors or decisions, illegal cancel layers, and
oversized payloads fail closed; no principal, approver, renderer, handle,
path, or runtime object is representable. Nothing imports these modules.

Verification: test/main/contracts (71 passed, 14 new), targeted tsc over the
new files, oxfmt --check, oxlint. Full typecheck:node/web fails only on
pre-existing dependency drift (tokenx, @ai-sdk/open-responses) reproduced in
the untouched main checkout; host Node is 22.22.0 against engines >=24.18.0.
Event data is read into a bounded plain copy before `JsonValueSchema` sees it. The recursive schema
threw `RangeError` out of `safeParse` for a payload deep enough to exhaust the stack (a `JSON.parse`
result at depth 4000 or 40000), which is not fail-closed. The read is iterative and bounded in depth,
node count, and encoded bytes, refuses cycles, array holes, accessors, symbol keys, non-plain
prototypes, and non-JSON values, and measures the copy it returns, so the byte budget is spent on
what a client receives.

Replay validation requires every replayed event to stay in the requested cursor epoch, not only the
first: a switch in the middle with contiguous sequences and a matching `initialCursor` used to pass
as one gap-free catch-up.

A snapshot's pending interactions must belong to the snapshot's session, and `expired` is no longer
accepted with `resumed: true`, because an expiry records a response that was not applied.

Verification: test/main/contracts 74 passed (17 in the event contract; 3 new tests); the new cases
fail against the previous sources (RangeError, mid-replay epoch switch, cross-session pending
interaction, expired+resumed). oxfmt --check and oxlint clean. typecheck:node/web fail only on
pre-existing dependency drift (tokenx, @ai-sdk/open-responses) plus pre-existing renderer errors;
host Node is 22.22.0 against engines >=24.18.0.
The event-data read pushed a frame for every element or key of a container before it looked at the
node budget, so the input's width, not the budget, decided how much memory the read materialized: a
five-million-element array was enumerated in full (10,000,002 own-key and descriptor reads over
13.25s) before being refused as oversized, and the comment claiming the node check "stops an
oversized payload from being copied in full first" was not true for width.

A container is now refused before it is expanded when its width cannot fit the node budget next to
the nodes already counted. The node budget is the byte budget — every node costs at least one
encoded byte plus the delimiter joining it to its parent — so this is a work bound, not a second
acceptance rule: a payload the byte check accepts has at most ~131k nodes, and the node check could
never have refused one the byte check accepts. An array's width comes from `length`, so a wide array
is refused without even listing its keys; an object's key list is what `Object.keys` returns, so only
that list is still built at the input's own width. Frames held at once are now bounded by the depth
budget times the node budget instead of by the input.

An own `__proto__` key is refused at any depth instead of being copied. `Object.defineProperty` kept
the key in the measured copy, but the record stage the copy is piped into writes keys by assignment,
and `__proto__` is the one key where that sets a prototype instead of creating an own property, so
the returned DTO was a different value from the one that was measured: `JSON.parse` of a document
with that key was accepted and came back without it. `-0` is refused too: it is encodable but not
preserved (`JSON.stringify(-0)` is `'0'`), so it does not survive the round trip the byte budget
measures. Every other number semantic is unchanged, and -0 is the only finite double JSON does not
reproduce.

Verification: test/main/contracts 77 passed (20 in the event contract, 3 new cases). Against the
previous sources the new cases fail: `__proto__` and `-0` are "accepted", the wide-array case reads
all 10,000,002 keys and descriptors over 13.25s where the new suite runs in 173ms, and the nested
budget case visits 131,071 children before refusing where it now visits none. oxfmt --check and
oxlint clean. typecheck:node (9) and typecheck:web (5) report only the pre-existing dependency drift
(tokenx, @ai-sdk/open-responses) and pre-existing renderer errors, with host Node 22.22.0 against
engines >=24.18.0.
Adds the Stage 1C client-facing adapter boundary and nothing else: the typed
handshake, submission, and submission-query DTOs, one closed operation
vocabulary, the operation-to-capability mapping, one pure refusal resolver, and
the adapter surface a binding implements. Stage 1A/1B are imported unchanged and
no runtime, transport, Electron, ACP runtime, or CLI implementation is added.

The submission DTO is the piece 1A/1B left open: 1A defined the receipt but no
request, so a submission had no idempotency identity to be receipted against.
`submissionId` is required, and a separate query request reads the receipt a lost
response would have carried, because a missing receipt is not proof that no run
started. The submitted text shares the snapshot's message bound, so a submission
a client may make is one the transcript can report back.

One adapter value with seven DTO-facing operations serves both bindings instead
of one interface per binding. Their feature differences stay visible as data: the
handshake returns the service's complete capability statement, an operation that
depends on a capability is refused through `resolveClientOperationRefusal`, and
`requiredClient` is copied from that advertisement rather than chosen by the
caller. A capability no client can supply reports null; absent, unavailable, or
self-contradicting advertisements refuse instead of reading as support, so a
missing capability can never be mistaken for a working one. Nothing in the
surface or in any request schema can carry an identity: no principal, renderer,
approver, clientKind, asDesktop, callback, AbortSignal, handle, or absolute path
is representable, and every object is strict so such a field is rejected.

Verification: test/main/contracts 114 passed (37 new in
agentServiceClientContract.test.ts); full test/main 9137 passed, 5 skipped, 1
file skipped. `pnpm run lint` and `pnpm run i18n` clean; `oxfmt --check .` clean
over 3010 files; typecheck:node and typecheck:web both clean on Node 24.18.0. The
repo's default typecheck gate does not include `test/**`, so the file's type-level
assertions were checked with a temporary tsconfig extending tsconfig.node.json
(test/main/contracts plus the agent-service contracts, removed afterwards): clean,
and it fails when the surface is mutated to take an options bag with an
AbortSignal.

Ablation: the shared surface is one interface plus one resolver, and the resolver
is the only place that decides a refusal, so an adapter cannot invent a required
client at a call site. Rejected as unproven: a generic RPC envelope, an adapter
registry or service locator, a capability index object cached per adapter, a
per-binding interface pair, a handshake echo of the requested version under exact
negotiation, a capabilities accessor beside the handshake, a caller-supplied
refusal builder, an AsyncIterable subscription (live delivery is the transport's,
Stage 4), and session lifecycle operations. The two fakes in the suite are
fixtures, not shipped adapters, and a dead capability flag in one of them was
made live or removed instead of left as decoration.
Four corrections and two boundary records on top of the Stage 1C client
adapter commit. No Stage 1A/1B DTO change, and no runtime, transport,
Electron, ACP, CLI, event, or database code.

**A capability advertisement must agree with itself.** `common.ts` validates
`reason` and `requiredClient` independently, so an advertisement could state
`requires_desktop_client` without naming a client, or claim
`requiredClient: 'desktop'` under `not_supported`. The handshake result now
refuses a self-contradicting set instead of repairing it, and
`resolveCapabilityRefusal` copies `requiredClient` only from a consistent
entry: everything else refuses with `null`, so no caller is pointed at a
Desktop lease the advertisement's own reason denies.

**The contract test's type assertions are compiled.** `expectTypeOf` is
erased at runtime and `tsconfig.node.json` covers `src/**` only, so the
statements that this surface takes exactly these DTOs were enforced by
nothing. `typecheck:contracts` compiles the client contract test together
with the agent-service contract sources, is wired into `typecheck`, and
fails when `submit` takes an options bag with an `AbortSignal` or a public
request type gains a host type.

**A binding that keeps no receipt no longer answers `not_found`.** A
receipt query now answers an outcome: `receipt`, or `receipt_not_retained`
when the binding cannot answer at all. `not_found` for a submission the
binding accepted and executed reads as "no run started", which is the
reading that licenses the resubmit this path exists to prevent. No new error
code was added, and this is a value rather than a capability refusal because
`session.persistence` gates the query while the same binding must keep
advertising it to express a snapshot; a receipt-retention capability id
would be a Stage 1A vocabulary change.

**An interaction answer is addressed.** The 1B response DTO names its
session, interaction, message, and tool call but no service instance, and
one client can hold adapters for more than one instance. `respond` now takes
a strict client-side envelope stating the instance plus the run and request
the published interaction belongs to. No principal, approver, renderer, or
client-kind field exists in it, and the 1B file is unchanged.

**Hand-off, where the next slice can cite it.** `client.ts` records that
live delivery is the local transport slice's concern and that no
`AsyncIterable`, callback, or long-connection handle is introduced here, and
that steering, the pending-input queue, and session lifecycle are not client
operations on this surface. A contract test pins both boundaries to the
frozen seven-operation vocabulary. `plan.md` is deliberately untouched.

Verification: `test/main/contracts` 121 passed (44 in the client contract
test); full `test/main` 9144 passed, 5 skipped, 1 file skipped;
`typecheck:contracts`, `typecheck:node`, `typecheck:web`, and the composed
`typecheck` clean; `oxfmt --check .` clean over 3010 files; `lint` and
`i18n` clean.

Ablation: reverting the consistency guard fails the two capability tests;
reverting the receipt-retention outcome fails the two query tests; dropping
the instance check in `respond` fails the addressing test; and the new gate
exits non-zero for a `submit` that takes an `AbortSignal` options bag and
for a submission request that gains `z.instanceof(AbortSignal)`.
The gate's diagnostic filter keeps only diagnostics that carry a scoped
file, so TS6053 ("File ... not found.") for an absent root was dropped,
the program simply got smaller, and the gate exited 0 — losing the
`expectTypeOf` assertions it exists to enforce. Scoped roots are now
checked against the filesystem before compiling, and the failure names
the missing relative path on stderr.
The root check used `ts.sys.fileExists`, which is true for a `chmod 000`
root, so an unreadable root still compiled a smaller program and the gate
exited 0 — the false green the missing-file check was meant to close.
Roots are now read: an undefined `ts.sys.readFile` marks the root absent
or unreadable, the report keeps the two apart, and stderr names the
relative path.

The gate regression test covers the unreadable root with a real
`chmod 000` (restored in `finally`, mode and content asserted), skips with
a note where mode bits are not enforced, drops the fragile empty-stderr
assertion, and normalizes separators in path expectations.
The gate compiled a smaller program and reported a pass whenever a scoped
root could not be read: the scope filter dropped file-less diagnostics,
which is where TypeScript puts the config, option, global, and missing-root
TS6053 errors, so only in-scope file diagnostics survived. File-less
diagnostics are now kept alongside `parsed.errors`, and the root
readability check stays for the missing/unreadable distinction. A config
using a deprecated compiler option now fails on TS5101 instead of passing.

The regression test no longer deletes or chmods the tracked contract test.
Each case runs a copy of the real script in a `mkdtemp` fixture holding a
minimal tsconfig, a scoped source, a scoped contract test, and a junction
to the resolved `node_modules`: pass, missing root, unreadable root,
in-scope type error, unparseable config. Path expectations normalize `\`
so they match `path.relative` output on Windows, and success asserts the
pass line rather than an empty stderr.

Verification (Node 24.18.0): `typecheck:contracts` and composed
`typecheck` exit 0; `test/main/contracts` + `test/main/scripts` 38 files /
382 tests pass, with the tracked client contract test byte-, mode-, and
inode-identical before and after the run and no fixture left in the temp
dir; `lint`, `i18n`, and `oxfmt --check .` (3010 files) clean.

Ablation, in fixtures: a `fileExists`-only root check exits 0 for an
unreadable root, and removing the root check while re-filtering file-less
diagnostics exits 0 for a missing root — both fail the suite with "expected
0 to be 1". Dropping in-scope file diagnostics fails the type error case.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Integrate upstream dev at 8cdf580 (merge base 5194235).
Eight content conflicts resolved keeping host re-exports while porting
upstream stream-revision changes into the kernel runtime owners.
Includes the M0 baseline corrections: kernel path mapping in
tsconfig.node.json resolves inside the repository, the Vitest 4
contract gate declares its node types explicitly, and architecture
baselines are regenerated from the merged tree.
Remove the 175 pure host re-export shims and rewrite production
callers to supported @deepchat/agent-kernel subpath exports. Add the
kernel-port structure typecheck gate that enforces the port pairing
contracts at compile time.
Promote 127 exact public subpaths into @deepchat/shared with
manifest-derived staging, isolated consumer closure, and exported
declaration validation. Fix the gate defects found in review:
unexported subpaths, relative and symlink artifact escapes,
dependency-cycle overflow, and Windows path separators. Add the
manifest-completeness regression guard.
The host chat barrel re-exported MessageFile through its own
@shared/chat barrel, resolving to any under skipLibCheck. Target the
package owner and add a compiler-symbol identity regression that
rejects any/unknown substitutions.
Relocate the Desktop app into packages/desktop following the M0
path contract: config-relative aliases, app-local out/ outputs with
Builder staging under Desktop, explicit Desktop cwd for Builder,
root-owned lockfile and pnpm policy. Root tooling stays
repository-wide.
Correct the 52-path relocation delta: CI helper imports and
workflow paths, native dependency resolution from the owning importer,
stale path and command assertions, and the xlsx integrity restoration.
Complete packages/cli with exported launcher artifacts and global
help/version output. Relocate the shared and kernel test suites into
their owning packages, reconcile formatting over the 301 formerly
excluded UI files, and wire the root aggregate Vitest configuration
across all projects.
Inject a complete synchronous transaction capability at the
session composition boundary. Tape fact, transcript projection,
cursor, usage and pending-input state with linked-message atomicity
stay on one connection; no generic persistence package is added.
Extract the portable provider runtime with injected config,
credential and OAuth ports (M5a) and the MCP transport, session and
process core on explicit ports with bounded stdio, token binding and
interactive refusal (M5b). Relocate the shared DeepSeek, failure and
trace values to @deepchat/shared and declare @types/json-schema
exactly.
Restore the mandatory root MCP artifact preflight with real
stdio and HTTP transports from staged artifact bytes. Add
manifest-derived cold source typechecks for provider and mcp, cross
platform compiler invocation through process.execPath, and the staged
declaration and runtime consumer checks.
The root aggregate resolved the desktop script tests' relative
imports as /scripts paths because the inline projects had no explicit
root; pin both projects to the app root. The kernel and provider
artifact closures rebuild the same shared dist, so move the two
writer tests into a dedicated serial artifact project and include it
in test:main. Use fileURLToPath for the include patterns so spaces
and Windows drive paths cannot silently drop the artifact tests.
Record the executed milestone evidence in the migration plan and
spec, and align AGENTS and README guidance with the actual engine
requirements (Node >=24.18.0 <25, pnpm >=10.34.5 <11) enforced by the
root package.json.
Integrate dev ebfe83a (17 commits): the sync host endpoint
feature, memory atomicity fixes, and the Baizhi MCP example plugin.

Relocation porting: the sync host module lands at
packages/desktop/src/main/sync/host with its tests under
packages/desktop/test/main/sync/host; the new syncHost contracts stay
desktop-owned beside the host routes barrel; rename detection carried
the memory, composition, logging and plugin test deltas; the Baizhi
example test resolves the workspace root relative to the relocated
test file. The merge touches neither the renderer surface nor any
extracted package manifest.

Post-merge gates: full typecheck including the kernel-port structure
gate, the affected sync, memory and plugin suites, format, i18n, lint,
and the full main aggregate (661 files / 9309 tests) all pass.
The compatibility projection created its own AbortController, so echo,
dispatch and the runtime helpers observed a signal that never aborted
when the ACP turn was cancelled, timed out or the peer exited. The
projection port now requires the caller's signal: AcpAgentInstance
passes the active prompt controller's signal into begin(), and the
adapter forwards it into IoParams instead of creating a substitute.
Regression: cancelling the active prompt aborts the captured
projection signal and reaches projection.cancel.
closeAll could hang forever when an ACP peer never answered cancel:
instance.close awaits cancel which awaits the peer, and the runtime
had no upper bound before its own allSettled. Every closeAll phase now
settles within a bounded timeout, leaving uncooperative close work
detached while the runtime maps are cleared; process-level termination
stays owned by the runtime owner's process manager shutdown.
Regression: a hanging instance close no longer stalls closeAll.
plugin.mjs and package-plugin.mjs resolved build/bundled-plugins,
dist/plugins and the native build script through process.cwd(), so a
root or release-helper invocation would read or write the wrong tree
and break root/filtered command equivalence. All three paths now
resolve from the script's own app root. plugin:bundle:clean becomes a
'clean' action in plugin.mjs with the same config-relative targets
instead of an inline cwd-dependent node -e command.
The root build produced shared, provider and kernel artifacts but
never built @deepchat/mcp, so a fresh checkout or a consumer running
only the standard root build could package against a missing or stale
MCP dist. build:mcp now runs in the root chain between provider and
kernel.
generateCompletionStandalone swallowed auth, network and provider
configuration errors by default and returned an empty string, which a
caller cannot distinguish from an empty completion. The portable core
now fails fast; swallowing is an explicit opt-in whose diagnostics log
only provider and model ids. generateCompletion logged the full message
array; it now records provider, model, temperature, maxTokens and
message count only. All existing callers already opt out of swallowing;
the desktop vision analysis path reaches its existing metadata
degradation handler with the error message instead of an empty result.
The root export depends on node: builtins, process.env and stdio
process lifecycle but advertised nothing; the manifest description and
the index doc comment now declare the surface Node-only. Default
diagnostics on connect, registry, transport, sampling, elicitation,
tool, prompt and resource paths logged raw error objects and remote
text that can carry URLs, auth hints, commands or user input; they now
record the operation, server id and an error category only. Errors
still propagate to callers unchanged.
The CLI guide's ownership table still pointed at the pre-migration
root layout; it now names packages/cli, packages/desktop/src/main/cli,
the shared wire contracts versus the Desktop adapter contracts, and the
kernel-owned command permission service. The standalone tracker's
kernel build record notes that the emitted-file count shrank from 492
at 2B-3a time to the current 348.
Normal generated registry refreshes produced by the root build chain.
serverLastErrors fed the renderer's lastError display and the
failed status event crossed IPC, both carrying raw error messages that
can include remote text, URLs, paths or auth hints. Both now carry the
fixed error category; the schema-validation warning and the model
display-name warning use the same category helper so the boundary is
uniform across console, diagnostics state and status events. Errors
still propagate unchanged to callers and protocol responses.
The generated providers.json dropped the upstream updated_at, so
a refresh could not be audited against its source revision. The
sanitizer now preserves it as source_updated_at. The carried refresh
was audited against PublicProviderConf dev e67c8272: all ten removed
model IDs are absent upstream and the remaining drift is upstream
movement after the snapshot.
Record the two post-merge review rounds and the registry refresh
audit trail.
The CLI could only be debugged through one-shot rebuilds; its
runtime specifiers resolve workspace packages, so running TypeScript
sources directly is not possible. cli:dev builds out/cli/deepchat.mjs
in watch mode with the desktop version, and the CLI package gains its
own dev script writing dist/ with a dev version. The guide documents
the loop: run the watcher and execute the artifact against a running
Desktop.
Replace the watch-mode dev build with the straightforward loop the
workflow needs: cli:build bundles out/cli/deepchat.mjs, cli:run
executes it with plain Node and forwards arguments. run-cli.mjs strips
the leading -- that nested pnpm run forwarding preserves verbatim,
fails fast with a clear message when the bundle is missing, and
propagates the CLI exit code. The guide documents the two-step loop.
The packaging workflows pass repository-root-relative paths
(extension-path, extension-base64-path, resources-path, report-path,
plugin-root) to desktop scripts that pnpm --filter executes from the
desktop package, so path.resolve doubled the packages/desktop segment
and the Windows VSS verification failed on the first real six-target
run of the relocated tree. The scripts now derive the repository root
from their own location and anchor those arguments there, making
resolution cwd-independent; absolute inputs and the script-owned
defaults are unchanged. Verified by running the VSS smoke from both
the repository root and the desktop package with the same
repository-relative argument.
Integrate dev a79f884 (5 commits): the AnonRouter provider, plugin
settings window route scoping, and the memory drain and scoped
retrieval fixes.

Relocation porting: the AnonRouter registry entry lands in
packages/provider and its renderer icon and new tests land under
packages/desktop; rename detection carried the memory, plugin,
provider and preload deltas. Conflict resolution keeps the kernel and
shared package import paths while adopting dev's new imports
(MAINTENANCE_DRAIN_TIMEOUT_MS, WORKING_REFRESH_DEBOUNCE_MS, withSoftDeadline
via the kernel memory core). Post-merge gates: affected suites
(285 tests), full typecheck, format, lint, i18n and the full main
aggregate all pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant