Files
deepseek-harness/scripts/gen-doc-graphs.ts
T
Xu Hanxiang 635d9c88f6 feat(liveshare): add the host, viewer, and client share surfaces (#6749)
* feat(liveshare): add the owner host, viewer host, and share runtime

The owner-side half of the bundle: the share host that publishes a Session to a Relay and answers one viewer per share, the owner credential/authorization records, the fork registry and its enforcement, the read-only viewer access policy and read budgets, the turn-requester fold, and the Relay gateway worker. The viewer host renders the served page's assets and declarations; no browser code is included in this layer.

* test(liveshare): cover the owner host, viewer host, and gateway

Keyless host-side coverage: share publication and restore, owner login/authority, fork registry and enforcement, viewer read policy and budgets, the turn-requester fold, the Relay tunnel worker and gateway commands, and the served viewer page. The browser surfaces are covered by the client layer's own suite.

* chore(build): register the host bundle in the compiler, dependency policy, and CI

The package manifest, Host compiler face, and tsdown host entries the bundle needs; the workspace constraints the built gateway/declaration artifacts require; the release-family and README model-experience rows; the coverage-lane exemption; and the keyless CI job that runs the share cases on Linux. The dependency-policy classifications wait for the client layer, which is where the package's client face (and its imports) first appears.

* docs(liveshare): record the bundle's subsystem, projection, and decision rationale

The package README for the host layer, the subsystem page, the persistence record that declares the viewer principal projection, and the note that keeps the protocol and owner-surface decisions with their evidence.

* feat(boot): offer the Liveshare host bundle in the CLI profile and plugin manager

The bundle joins the optional bundles the CLI installation carries and the Official group the plugin manager lists, so an owner can switch it on without editing a profile by hand. The plugin-manager and plugin-config expectations follow in the client layer that renders the card's browser half.

* chore(docs): regenerate the graphs, catalogs, and persistence records for the host

Run the generators over the host layer: the event producer/consumer graphs, the persistence catalog and schema inventory, the config catalog, the module graph, and the Cordis API catalog. The persisted viewer-principal event appears in the catalog and the historical format index counts it.

* fix(liveshare): keep the bundle's locale display metadata in this layer

The CLI profile registers the bundle as optional, so the plugin manager reads title and description from ./locale/*.json. Ship the two locale files and their export here; the browser half is not needed for display metadata.

* refactor(liveshare): read the turn boundary through the typed Agents service

The retained projection-handle migration left two assertions converted through unknown; ctx.get('agents') already exposes turnBoundaryOf, so the host and the cancel-admission case call it directly and the unknown-cast baseline stays untouched.

* test(liveshare): expect the canonical viewer tier from the three-mode harness

The Relay reports a share published with the withdrawn fork-readonly tier as view and carries the fork grant separately, so the harness compares the canonical tier and forwards an explicit allowsFork when the case names one.

* refactor(liveshare): share the reader-facing record and body readers

The fork decision and the access snapshot built the same share facts twice, and the two share-writing routes read the same share-naming JSON body twice; both pairs are now one helper each, so jscpd no longer reports them. Behavior is unchanged.

* test(web): record the host layer's optional-bundle expectations

Registering the LiveShare bundle in the Official group adds its card and description to the plugin manager and plugin-config pages, and the packed preview must state that its host plugin needs LIVESHARE_RELAY_URL instead of a generic applied toggle. Refresh the affected goldens locally (keyless web snapshot refresh) and keep the card badge at this layer: the client list that labels it experimental arrives with the client layer.

* docs(liveshare): mark the shipping owner policy note implemented

The protocol-and-owner-surfaces decision shipped with this bundle, so the note moves from proposed/ to implemented/ in both languages, its proposal-era headings become Decision/Verification/Consequences, and the subsystem page plus package README link to the new path. The pairing record moves with the triplet.

* fix(liveshare): judge tool turns from the open Session turn and recheck the narrowed write

The master baseline has no ToolExecutionInput.turn, so the host derives the turn a tool execution serves from the Agent's Session: the latest turn/start with no turn/end after it. A missing Agent, Session, or open turn yields no turn and the fork guard refuses the execution (fail-closed) instead of reading it as the owner's. Also refuse a fork that names a child without a bound rpcId/operationId, classify forwarded paths by their normalized pathname so /api/session/./fork or a query suffix cannot skip the fork decision, and ask the write gate again after the final authorization refresh so a share narrowed to view during fs/write-intent cannot write. The subsystem page, package README, and model-experience census follow the two-tier model and the bounded-write child ceiling.

* refactor(liveshare): read the open turn beside the requester fold

The derivation reads the same Session log the requester fold decides, so it
lives with that fold and the host imports it instead of keeping a private copy.

* test(liveshare): cover the derived turn and the refusal paths

Drive the host's real derivation from Session events: the open turn of the
Agent's Session decides a viewer turn is confined and an owner turn is not,
while a missing Agent, an absent open turn, and a requester the host no longer
drives all refuse instead of being read as the owner's. Cover the three safety
behaviours the host commit added: fork child naming without a bound request
identity, dot-segment/query fork paths that must still reach the fork decision,
and a share narrowed to view while fs/write-intent awaits.

* fix(liveshare): avoid the deprecated snapshot reader and the extra casts

The host derived the open turn by scanning the Session event log, which the
deprecation note forbids for new production calls. Read the same state from the
turnBoundary projection the Agents service maintains instead, and let a spec
supply a fixture boundary reader where production supplies turnBoundaryOf.
The live attribution of an execution is unchanged: an absent boundary or one
between turns still yields no turn and the guard refuses it fail-closed.

* docs(liveshare): describe the bundle's indirect model experience

The bundle's Model Experience moved to the audited indirect classification,
so the README sentence now names the path: viewer prompts the owner host
forwards into a shared Session drive that Session's model requests. The
KV-cache paragraph and the bilingual pairing record follow.

* test(liveshare): pin the turn-start admission for steered and admitted reader input

The pre-step admission already re-reads Relay authority from each entering message's own source and refuses queued reader input whose grant ended before the model runs; the new cases pin the steer path and the admitted viewer turn, so the turn-start half stays proven next to the delivery-time recheck the Session Controller owns.

* test(liveshare): adapt the replay and cancel expectations to the parsed tier

* test(liveshare): align the access-state expectations with protocol v2

The parsed share-access-state frame always carries allowsFileUpload, the
withdrawn fork-readonly word no longer implies a view tier or forking, and a
hand-built frame may not name it. Update the hand-built expectations and the
two fixture specs that named the retired word, without touching production
semantics.

* test(liveshare): record the in-flight cancel race after the controller revert

The Session Controller no longer re-reads a caller's access before cancelling,
so a cancel admitted before the reader's grant ends keeps its effect. State that
as the known limitation the earlier refusal tests documented instead.

* feat(liveshare): add the viewer attachment-upload switch

The share record, registration handshake, access-state replay, and share-viewer
policy carry an owner switch that is off by default: it admits fileUploads/upload
on the shared Session and the viewer's own forks, is independent of the tier and
of forking, and is normalized to off at every untrusted state-file or frame
boundary.

* docs(liveshare): state the sandbox tiers and the attachment switch

Name the two execution tiers as a read-only sandbox and a bounded writable
sandbox, and document the independent default-off switch that lets admitted
viewers upload attachments, including that legacy shares read as off.

* refactor(liveshare): drop the withdrawn fork-readonly migration

The feature never shipped, so no stored record can name the withdrawn tier and
the boundary needs no compatibility path. Read a stored fork switch as exactly
what it states, let an old Relay state the switch explicitly instead of
inferring it from the tier, and stop accepting the retired mode name on create
or restore.

* fix(liveshare): preflight a resumed child's limit before publishing it

A resumed child is announced through agent/created before its execution limit
installs, so an installability failure left a created Session whose guard never
landed. Resolve the recorded child's granted workspace before activation and
refuse the resume there, so a child this host cannot confine is never published.

* refactor(liveshare): render the viewer page through the reader's own Peer

The served-page seam now hands the frontend reader the reader's Peer, which renders that page's own
opening access rows; the Liveshare side no longer assembles a global access snapshot and the removed
global is gone. The viewer read service builds the same Peer it already creates for every dispatch and
passes it to the asset reader.

* fix(liveshare): keep the owner upload switch across an older Relay's access frame

The Relay is the authority for what a viewer receives, but only for the fields its frame states. An
older Relay predates the upload switch and omits it entirely; the protocol parser rightly normalizes a
missing switch to off for frames that state it, so the host must inspect the raw answer to tell not

* test(liveshare): adapt the host specs to the current tool and session APIs

The host specs drove a single tool through the pre-batch executor shape and handed
some tests a Session outside its store; the current tool executor takes a call batch
and Session appends require the owning SessionStore. This adapts those fixtures to
the current shapes without changing what each test asserts.

Also gives the reader's own fork the same Session permissions as the source grant:
forking its own child again and renaming it are allowed on the child only, while the
source Session still refuses renaming and keeps forking as its one Session verb.

* fix(liveshare): let the owner's background turns run while a share is served

The requester fold only counts a message with `source.kind === 'user'`, so a
turn a background event opened has no attributable input and stayed `unknown`;
the source-session tool guard refused every tool in that turn. Establish the
owner's own turn for an input-less turn and leave unattributable input unknown.

* fix(liveshare): grant the reader its own fork's rename and gate child forks by the fork switch

* refactor(liveshare): read the served page through webServer.readResource

* feat(boot): offer the Liveshare host bundle from the official bundle list

`@deepseek-ai/dsh-official-bundles` owns the shipped template, retired, and
optional bundle lists now, so the liveshare bundle joins `OPTIONAL_BUNDLES`
there instead of being restated in the app-boot profile module.

* feat(liveshare): gate the reader's own fork independently of its input permissions

The reader's child gets prompt, cancel, and rename from a role with no extra
condition, while forking the shared Session or one of the reader's own forks
belongs to a separate role bound to the owner's fork switch. A viewer cannot
rename the shared Session, and a withdrawn fork switch also withdraws forking
a child.

* test(liveshare): adapt the admission specs to the current tool and driver APIs

* feat(liveshare): accept only the declarations a plugin's own package owns

A viewer plugin may contribute endpoints a share can offer, so a declaration
has to name a live Host Remote endpoint whose generated id belongs to the
declaring package. Fail closed: no loader entry, no local endpoint, no owning
package, or a foreign owner all refuse the declaration, and the recorded
attribution is the package name the owner sees.

The package's Typert artifact registers after its apply runs, so a plugin
declares once that registration has happened.

* feat(liveshare): classify every Remote endpoint before the reader policy decides it

The share-viewer policy refuses by default, so the endpoints it decides were a
handwritten whitelist that could drift from the generated endpoint set. State
every endpoint's reason in one shared table: the Client face proves it covers
`RemoteEndpoint` (both directions), the Host derives `POLICY_DECIDED_ACTIONS`
from the reader entries, and a load-time assertion fails if the derived set and
the roles' actions disagree.

* fix(liveshare): declare the Client face its classification gate compiles in

Typert attributes a package's exports to a face only for manifests declaring
`dsh.client` plus a `./client` subpath (`isDualFacePackage`). Without that row
the client catalog read `exports["."]` — the Host entry — from the Client
program, which holds only the classification table and its completeness gate,
and failed on the source that program does not contain.

Export the gate module as `./client`, declare the Client row, publish its
bundle, and exempt the package from Client/Host dependency treatment: the
runtime importing these dependencies is the Host's, and `dsh.client` here only
attributes the compilation face.

* test(liveshare): align worker fixtures with the protocol tenant contract

The protocol layer rejects a share RPC actor whose tenant is empty at the IPC ingress. The worker fixtures still described a peer normalizing the tenant to an empty string, so the Host dropped every fabricated request event and the lifecycle and correlation polls timed out. Use a concrete tenant in the fixtures; those scenarios do not depend on an empty tenant.

* chore(docs): count the liveshare persistence nodes in the format index

The regenerated current-checkout catalog adds the liveshare viewer-principal nodes, so the v4 row's node count moves from 622 to 626. Refresh the generated format index rather than keeping the pre-liveshare count.

* refactor(liveshare): consume the protocol package's single public entry

The protocol layer now publishes only its root entry, so the Host face reads the wire codecs, identity contract, and viewer-state vocabulary from the package root instead of the removed /protocol and /identity subpaths.

* fix(liveshare): classify the schedule cleanup endpoint before the Client gate reads it

The Client completeness gate compiles against the generated Remote endpoint map; master added schedule/deleteInactive after the table was last filled. It mutates the owner's schedules like its neighbours, so it stays owner-only.

* chore(release): carry the liveshare package to the rc.3 family version

* docs(liveshare): align the host docs with the shipped two-tier shares

The subsystem page still described a withdrawn fork-readonly compatibility, a
write-only source drive, and an immutable execution.turn. State the two tiers,
the independent fork and upload switches, the refused unknown mode, and the
turn derived from the Session boundary. Mirror the Chinese pages and re-record
the pairing hashes.

* fix(liveshare): describe the writable tier in the plugin card

The package description, both locale descriptions, and the three Web goldens
still told the owner that sharing is read-only. State driving, bounded writes,
forking, and the independent upload switch instead.

* chore(liveshare): forward the owner credential file from the bundle patch

The README lists LIVESHARE_OWNER_CREDENTIAL_FILE beside the other owner
settings, but the patch left it to a direct process.env read. Pass it through
the same config field as its siblings.

* docs(liveshare): drop stale fork module comments

The registry header still said read-only forks only, and both the registry
and the policy carried an orphan JSDoc block in front of the real one.

* refactor(liveshare): drop unreachable fork tool effect fields

ForkToolRequest.writesWorkspace and changesConfiguration had no producer in
the repository: the guard never sets them, and the only callers were the
tests. The write-denying branch also contradicted WRITE_FORK_CEILING, which
admits the bounded write tool. Remove the fields, their refusal codes, and
the test cases that manufactured them.

* refactor(liveshare-protocol): retire the attachment-upload capability

Sharing itself now carries attachment upload, so the protocol no longer
states an independent switch: `LIVESHARE_CAPABILITIES` drops
`file-upload`, and share specs and access-state frames drop
`allowsFileUpload`. Wire frames stay v1 and no Relay is deployed, so no
compatibility layer is needed.

* refactor(liveshare): grant attachment upload with visibility

The owner controls sharing, audience, the tier, and forking only.
`fileUploads/upload` now rides the Session read role, so every Session a
share serves the viewer accepts files, independent of the tier and the
fork switch. The owner record, the share APIs, the registration handshake,
and access-state adoption no longer carry an upload switch; an older owner
state file that still contains the field loads with it ignored.

Accepted risk, recorded in Known Limitations: an owner cannot refuse
uploads for an admitted viewer.

* feat(liveshare): share the owner wire records

Move the owner identity report into a module both compiler faces can import, and
declare the share-management request, result, audit, and capability records there
with the same fields the same-origin routes already answer. The Remote failure
vocabulary joins the same module so a client learns the liveshareShares/ codes
without importing the Host.

* refactor(liveshare): extract the owner share operations

Move the twelve share-management handler bodies out of the route closures into
operations implemented once inside apply(), leaving each HTTP route with its JSON
parsing, normalization, and legacy status/body. Each operation documents the
side-effect order it keeps and what reordering would break.

* feat(liveshare): serve share management over Typert Remote

Register a LiveshareShares Remote service over the same operations the routes
call, publish its ./typert and ./remote artifacts plus the shared ./types module,
and make the shared records part of the Client compiler face. The owner page gains
the Remote endpoints while the HTTP routes stay as thin adapters.

* chore(docs): regenerate the Cordis catalog for share management

Record the liveshareShares service on the LiveShare subsystem page, link its
wire records to that page, and regenerate the runtime API catalog. The service
declaration moves to the package entry so the host-face catalog projection can
render it.

* test(liveshare): refuse share management to viewer Peers over Remote

Cover the owner Peer's Remote list and the share-viewer denial of list, create,
stop, and setRelay. The refused calls leave the share list empty, the selected
Relay unchanged, and the worker channel without an add-share.

* chore(liveshare): mark the typert resource follow-ups behind #7003

The share-management Remote ships against today's resource registration. Default
resource resolvers and the stricter identity rules arrive with #7003, so the two
places that will change carry a TODO naming the PR.

* fix(docs): classify the liveshareShares service role

The share-management Remote adds one owner-side service, so the capability
seams graph owed it a role row; without the classification the generator
refuses to render and doc-sync fails.

* fix(liveshare): complete the bundle metadata and restore the static gates

The bundle shipped only two of the eight shipped-language metadata files, and its
browser face had no named tsdown config, so the locale metadata gate and the
browser bundle walk both failed. Ship the full locale set and name the client
build; share one owner-body registrar between the two POST adapters so jscpd
stops reporting an eight-line clone.

* fix(liveshare): give the bundle a client source route and a typed peer double

The product-use walk follows `${name}/client` for every mounted bundle, so the
package needs the source mapping and a Client entry the mapping resolves to.
Replace the unknown-cast peer double with a complete PeerScope literal.

* test(liveshare): adapt the viewer admission rows to the Session default resolver

#7003 replaced the `session/page` and `session/follow` resource readers with the
Session kind's default resolver. A body carrying both `sessionId` and `address`
is now refused as invalid resources before the share policy reads either, while
a lone hidden `sessionId` still resolves and is refused by the policy.

Records the new expectations; the served page names one identity per request.

* feat(liveshare): resolve create's request Session through the default resolver

#7003 registers the `session` kind's default resolver with the Session
Controller, and `liveshareShares/create` names its Session in the one request
object, so the Gateway resolves that Session for a non-owner Peer without a
reader declared on the method. The owner Peer reaches the operation without
an access decision.

Adds the Gateway resource assertion and the owner-path assertion to the
share-management security spec, regenerating the Cordis catalog.

* test(liveshare): refuse a viewer body naming two Session identities

A prompt or cancel body carrying both `sessionId` and `address` reaches the
Gateway, whose Session resolver refuses it as invalid resources, so the target
child operation never runs and no request content comes back. Prompt and
cancel requests name their Session by `sessionId` alone.

* test(liveshare): widen the two-identity coverage across the reader endpoints

The resolver refuses a request that names two Session identities for every
namespace, so cover the remaining reader endpoints with one parametrized case
instead of leaving them to the generic Gateway tests.

* refactor(liveshare): remove the unreferenced gateway heartbeat policy

The production worker keeps its own interval and missed-pong counter, so
nothing imported this module; only its spec did.

* refactor(liveshare): extract the owner state persistence

The state file reader, writer, serialized write queue, and stored-share
record types move out of the host plugin unchanged.

* refactor(liveshare): extract the Relay authority reader

The access-state cache, its generation fence, the admission rule, and the
authoritative read that adopts a frame move behind one factory.

* refactor(liveshare): extract the tunnel worker supervision

Worker start, settlement, registration replay, expiry timers, owed Relay
rows, and the credential state each registration presents move out of the
host plugin behind one factory.

* refactor(liveshare): extract the owner share operations

The twelve operations keep their order and side effects but move behind an
explicit deps factory, so the host plugin mounts them instead of owning
their bodies.

* refactor(liveshare): extract the owner routes and source access

The sixteen owner HTTP routes, their request adapters, and the credential
store move behind registerOwnerHttpRoutes. The confined source Session
layer and its turn control move to source-access, which the share-host
re-exports for its public helpers.

* refactor(liveshare): split the viewer reader into dispatch, fork, and lifecycle

The request entry and endpoint handlers, fork orchestration and branch
facts, and the streams/calls/Peers lifecycle move out of viewer-read,
which keeps the deps types and assembly. Order, TOCTOU re-checks, and
Peer ownership are unchanged.

* refactor(liveshare): share the wire JSON helpers and the fork enforcement record

The split duplicated `json`/`isRecord` across the extracted owner modules and the
child enforcement options at three call sites; the stored ACL selection type also
lost its reference to the wire type. One helper module and one record factory keep
the split modules from carrying their own copies.

* refactor(liveshare): move the shared owner record and one-definition helpers

The split published OwnerShare from the assembly while every extracted module
imported it back, and left each host module carrying its own isRecord. Move the
share record and its ACL summary to a neutral module, name the two optional
service seams instead of inline casts, and import one wire JSON helper.

* refactor(liveshare): remove the fork-enforcement audit event

Nothing consumes `liveshare/fork-enforcement` except the spec that watched
it, so the minimal-surface rule drops the declaration, the reporter, and
every report call instead of reserving an extension point for later.

The wiring spec now asserts the outcomes an observer could previously
infer stages from: an install that returns nothing leaves
`ForkRegistry.enforcementInstalled` false and the child's prompt gate
refused with `fork-ceiling-uninstalled`. The subsystem prose no longer
names the removed refusal code, and the Cordis/doc catalogs are
regenerated.

* fix(liveshare): surface fork enforcement failures

Three registration paths swallowed the error behind a fail-closed
refusal. Keep refusing, but carry the reason:

- `preflightForkChild` now throws `fork <id> preflight failed: <message>`
  when the stored child cannot be read, instead of returning and letting
  activation fail later; `share-host`'s `prepareChild` caller already
  propagates that through the viewer's `fork-prepare-failed` answer.
- A child's confined `read`/`write` registration keeps its error in a
  `ForkEnforcementFailure`, so `prepareChild` throws
  `fork execution limits are unavailable: <message>` with the error as
  the cause instead of the bare message.
- Source-session access keeps the refused registration on the
  installation and logs it beside the existing confinement refusal.

The wiring spec asserts the preflight failure and both captured errors.

* refactor(liveshare): move the client placeholder face out of this layer

The `./client` entry existed only so the package's Client face could
compile the viewer endpoint classification against the generated Remote
map; it published no runtime this product used. P3b owns the real Client
face, so P3a drops the placeholder instead of shipping a surface ahead of
its consumer:

- delete `src/client/*` and the package's Client tsconfig; the endpoint
  spec keeps the Host-table diagnostics, and the Host still derives the
  policy action set from that table
- drop `dsh.client`, `exports["./client"]`, the `lib/client.js` publish
  entry, the browser tsdown build, and the package path alias
- remove the dependency-policy exemption and the devDependencies only
  those client sources imported
- the Host tsdown face still skips the Client build pass; `./typert` and
  `./remote` stay Host-generated and the Client tsconfig never fed them
- README and subsystem prose no longer claim a browser face

* chore(ci): drop the stacked-PR e2e dispatch input

The narrow dispatch scope existed only to verify a stack's exact SHA while its
ordinary pull-request checks were unavailable; it is not part of LiveShare and
never belonged in this layer's diff.

* feat(liveshare): add the share dialogs, viewer seats, and client surfaces

The browser half of the bundle: the owner's share dialog and management surfaces, the owner sign-in flow, the viewer page's session plugin, brand, status, and seat components, and the read-only permission and model seats that replace the shipped composer controls at a shadow rank. The package README regains the client implementation links this layer provides.

* test(liveshare): cover the client surfaces and viewer seats

jsdom coverage for the share dialogs, owner login and identity, the viewer page composition, the read-only seats and their shadow rank, the served-page artifacts, and the client-face registration. The host-side suite stays in the lower layer.

* chore(build): ship the Liveshare client bundle

The client compiler face and solution reference, the tsdown client entry beside the host phase, the manifest's client export, client runtime files, locale assets, and client dependencies; the dependency-policy classifications whose consumers appear with the client face; the generated client slot catalog; and the plugin-manager expectations that list the bundle's card.

* fix(liveshare): shadow the shipped brand seats instead of colliding with them

sidebar.brand.mark and sidebar.brand.name are single slots, and a served viewer page mounts the owner's plugins, so on an official build ui-brand-official already registers both at the default rank. Register the viewer entries at a shadow rank and pin the collision in a registration spec, as the composer seats already do.

* fix(liveshare): clear the viewer's composer blocks when its plugin is disposed

The honest-composer effect raised a per-Session block but its disposer only unsubscribed, so a disposed or replaced viewer page left the owner's composer blocked. Track the Sessions it addressed and clear their blocks on disposal; a spec pins the recovery.

* style(liveshare): drop unnecessary type assertions in the registration specs

The slot test runtime's renderSlot already accepts these owner props, so the contracts-ready lint reports the as-never assertions as unnecessary.

* test(web): mark the client layer's LiveShare card experimental

The client layer ships the card that renders the bundle, and the recorded goldens already carry its Experimental badge. Add the bundle to the manager's always-held extension list and to the presentation's experimental set, and expect badge 1 in the plugin-manager spec.

* fix(liveshare-client): return to the share after viewer sign-in

The viewer page names its share in its own query (?shareId=...), so a
sign-in that returned to the bare path brought the reader back to a page
that no longer named their share. Build the Relay sign-in entry from the
page's path and query, and keep the entry's construction in one testable
function.

* fix(liveshare-client): state a half-saved share permission accurately

Saving the share permission writes the session tier first and the plugin
endpoint selection second. When the second write was refused the dialog
claimed the whole save had failed, though the tier and fork switch were
already stored. State which half landed, and re-read the host's record so
the fields show what it kept while the refused endpoint pick stays for the
retry.

* fix(liveshare-client): route viewer copy through the client locale seat

The viewer surfaces translated their own copy from navigator.language, so
a language chosen in Settings could not reach them and a Node boot read the
machine's language instead of the page's. Register the package dictionaries
once, as the client face already does, and take the bound translate seat:
React seats get it from their locale registration, and the plain-DOM status
banner takes it from the plugin and repaints on locale changes.

Also move the viewer status banner's copy and reason helpers to that seat,
and update the viewer specs to provide a LocaleRuntime the way the assembled
viewer page does.

* test(liveshare): assert the two-tier fork ceiling in the viewer artifact

The branched row states the ceiling the host installed: a view-tier
(fork-readonly reads as view) share's child runs under the read-only fork
ceiling, while a write-tier share's child carries the bounded write
ceiling, whose host half readonly-fork-cold-child.spec.ts covers. Read
that statement through the shipped Tooltip's pointer events: after the
rebase it listens to pointerenter/pointerleave, so the previous mouse-event
hover never raised the bubble and the row's statement stayed unread.

* feat(plugins): hide LiveShare until its plugin rows run

A default installation ships the LiveShare package but composes none of
its plugins, so the plugin manager must list it nowhere; enabling it
through the profile's cordis.patch.yml or its bundle selection starts
those rows and only then shows the card under Experimental plugins.

The visibility rule reads the bundle rows' own run state rather than the
profile's bundle selection, because a row inserted through cordis.patch.yml
runs while the bundle stays unselected. The hidden bundle ID stays
addressable, so openBundle still opens its details.

* test(web): assert the hidden LiveShare card across both lists

The plugin-manager scenario now checks that the default profile lists no
LiveShare card in the Experimental plugins group or on the More page, and
that the offered count is one lower than the shipped optional bundles. A
dedicated scaffold writes the bundle's declarations row into the profile
patch, waits for the loader to recompose it with bounded timeouts, and
checks the card joins the list and leaves it again when the row is removed.

* docs(plugins): document LiveShare's default hidden state and enablement

The package README now states that a default installation shows LiveShare
in neither plugin list, and gives both enable routes: inserting the
bundle's two entries in the profile's cordis.patch.yml (placeholders for
the deployment's Relay identity values, with the recomposition caveat), or
selecting the bundle in dsh.profile.bundles. It also states that removing
or disabling the rows takes the card away again. The subsystem page keeps
the rule and links to the README for the exact entries, and the plugin
manager README describes the hidden-until-running list behavior.

This also repairs the README's pre-existing Model Experience mismatch with
its audited indirect classification, which failed the documentation gate
before this change.

* docs(agent-notes): record LiveShare's hidden-until-running rule

Record why the plugin manager keys visibility on the bundle rows' run state
rather than the profile's bundle selection (the cordis.patch.yml route
leaves the bundle unselected while its plugins run), and why the detail
entry by bundle ID survives the hidden card. The rejected alternatives and
the costs of the decision are stated for review.

* test(web): expect the hidden LiveShare card in the configuration scenario

The configuration-page scenario reads the same More list as the plugin
manager, so its offered count is one lower than the installed optional
bundles while LiveShare's declared rows do not run.

* test(web): re-record the plugin goldens without the default LiveShare card

Refresh goldens remove the LiveShare entry from the Experimental plugins
list of both plugin-manager snapshots and from the plugin-configuration
snapshot, and drop the refresh card count from 12 to 11. Re-analysis under
replay confirms both scenarios green.

* fix(liveshare-client): release the viewer page state with its plugin

The viewer context facts, capability, and status stores are module-level so
every viewer surface reads one page's answer, but nothing released them when
the plugin owning the page was disposed or HMR-replaced, so the next mount
inherited the previous page's share facts, tier, and status. The viewer
session plugin now owns that lifetime: it clears the stores when it mounts
and disposes, and advances the read generation so a previous page's in-flight
answer cannot publish into the new page. A spec pins the disposed page and
the inherited state.

* feat(liveshare-client): add the viewer attachment-upload switch

The share carries whether viewers may upload attachments as its own switch,
off by default and independent of the session tier and the fork switch: a
view-tier share may allow uploads and a writable one may refuse them. The
share dialog sets it in both the create and edit flows and submits it with
the permission write; the manager sets it per share beside the fork switch.
A share the Host does not report reads as off. The dialog also states the
tier as the sandbox it runs in, so neither tier is described as carrying
file uploads.

* docs(liveshare): describe the sandbox tiers and the upload switch

The host layer owns the package README statement of the two tier sandboxes
and the upload switch; this layer's documentation adds the subsystem
contract for the switch and the client README's attachment-intake sentence —
file intake follows fileUploads/upload, which a share grants only while its
own switch is on.

* refactor(liveshare-client): drive the owner dialogs through the modal layer and shared primitives

Both owner dialogs ran their own focus trap beside Modal's layer, so initial focus, Tab wrapping, Escape
and focus restoration were each installed twice and could disagree. The hand-written trap is removed;
the shared layer owns the keyboard lifetime, including the dialog's own restore target. The hand-written
returns focus to the opener exactly as before.

The same surfaces also stop inventing their own controls: the session permission and the share lifetime
are SegmentedControl choices like the capability tier, and the independent fork and upload switches are
the shared Switch primitive. Stopping a published share now asks for the shared RiskConfirmation
acknowledgement before the destructive request runs.

* feat(liveshare-client): state the narrowed owner copy and correct the viewer surface

The share manager no longer promises a request-and-approval flow that the protocol dropped: its
description states the online state and access scope it actually shows. Stopping a share is now gated
on acknowledging that the published link stops working immediately.

The viewer status banner keeps its placement inline but takes its colors from the theme tokens with
system-color fallbacks, so the served page stays readable when the client stylesheet never loads. Both
owner dialogs size against the Modal padding box instead of a second viewport calculation, and a long
list title wraps instead of pushing the status out of its row.

A forked branch is stated as a branch, without claiming a read-only workspace the write tier does not
have; the same correction applies to a branch whose sandbox limits failed to install.

* fix(liveshare-client): align the viewer face with the current Client services

The rebased Client surface no longer carries uiConversation.noticeBranchRefusal: a refused branch leaves the view unchanged, so the viewer row consumes the rejection and the refusal-notice tests become containment tests. The plugin manager's bundle lists moved to dsh-official-bundles, so the liveshare bundle joins the centralized experimental and extension lists. Master's sidebar keeps the brand identity out of the accessibility tree when the page cannot start a Session, so the assembled-page test reads the viewer home link from the DOM. The roster gains the file-drag plugin ui-chat and ui-attachment now inject, and the fixture reads the protocol's Client-face carrier from its own project and brands the fabricated reasoning effort.

* refactor(liveshare-client): consume the protocol package's single public entry

The protocol layer now publishes only its root entry; the client tests read the wire and identity contracts from that root. The dependency policy keeps one liveshare entry carrying both the protocol vocabulary and the identity helpers.

* feat(plugin-manager): leave the LiveShare bundle unranked in the extension list

The bundle already stays invisible until one of its patch rows runs, which
keeps ordinary users from encountering it. Removing its ranking entry means an
internal profile that enables it finds the card at the end of the experimental
list instead of pinned to the front; the Experimental badge and the hidden-
until-running card both stay.

* refactor(liveshare-client): drop the attachment-upload switch

The share dialog and share management present only the tier and the fork
switch; attachment upload follows visibility on the Host, so there is
nothing left to toggle. Removes the field from the client share record,
both locale seats, and the dialog tests, and states the new rule in the
viewer README.

* feat(liveshare-client): drive share management through the liveshareShares Remote

The owner dialogs stop posting the twelve management routes and call the
generated namespace instead. The share list, creation, stopping, permission and
endpoint writes, the Relay read and override, the owner identity read, the
audit, and both Relay checks resolve to a RemoteResult, so a refusal is read
from its `liveshareShares/` code instead of an HTTP status. The sign-in round
trip keeps its three same-origin routes.

The package's client entry mounts its own generated contribution and passes the
namespace to the share surfaces through the slot inject face. `dsh.client.inject`
now names `@deepseek-ai/dsh-api-remotes`, and the viewer endpoint table
classifies the twelve management endpoints as owner-only so the Client
completeness gate stays total.

Client specs substitute the namespace with a scripted double instead of stubbing
`fetch`; the assertions read the recorded requests.

* test(liveshare): drive the owner host cases through the Remote service

The host cases no longer register a route map and post JSON to it. They call
`ctx.liveshareShares` directly, which runs the same OwnerShareOperations the
remote Gateway dispatches, so each case keeps its side-effect coverage and
asserts the RemoteError code and details a refusal carries instead of a status
and body.

The ping case is gone with the route: the header action reads `relay()` for the
effective address, which the Relay-state cases already cover. The e2e harness
publishes through `liveshareShares/create` over the owner's own `/api` carrier,
and the sign-in/credential cases keep their same-origin routes.

* refactor(liveshare): remove the owner share-management HTTP routes

The twelve same-origin management routes are gone, together with the thin
adapter layer that existed only for them: the status/body error translation,
the per-route request-body readers, and the capability-selection parser. Every
side effect stays in OwnerShareOperations, which the `liveshareShares` Remote
dispatches.

The four remaining routes are the sign-in and credential ones:
ownerLoginStart, ownerLoginRedeem, ownerLoginCancel, and ownerCredential. The
route-registration case now asserts exactly those.

* docs(liveshare): describe share management as the liveshareShares Remote

The package README (both languages) and the subsystem page now state that the
share list, creation, stopping, permission and endpoint writes, Relay state,
identity, audit, and both Relay checks are `liveshareShares` methods, and that
the sign-in round trip and credential storage are the only same-origin HTTP
routes left. The owner source table points at share-operations.ts and
share-remote.ts for management.

Re-record the README pairing hashes and regenerate the Cordis surface sections,
including the inspect catalog's service description.

* test(liveshare): restore the Client classification gate

P3a dropped the Client-face completeness gate together with its placeholder
face. P3b owns the browser runtime, so the gate returns: the Client program
proves every generated Remote endpoint has a table row, and the spec pins the
compiled export beside the diagnostics the Host also uses.

* chore(build): restore the Liveshare Client compiler face

The rebase onto the Host layer lost the solution reference and the Host
exclude that kept `src/client` out of the Host program. The package now
carries a real browser runtime, so it takes the ordinary Client/Host
dependency treatment instead of the exemption its placeholder face needed.

* docs(liveshare): restore the package's client faces

The subsystem overview names the client faces again now that the browser face
is back, and the translation pairing record is re-recorded.

* test(liveshare): cover the browser surfaces and client readers

The browser half of the package now meets the per-file gate on its own
terms: the viewer request decoder, the share-type readers, the viewer
session selection, and the owner identity readers are pinned by direct
specs, and the seats, brand row, share-path chip, status banner, model
seat, and menu row gain the cases their uncovered branches named.

Two internal simplifications remove branches that no input can reach: the
brand row strips build metadata with one replace instead of a split whose
second operand was unreachable, and the viewer-session subscription no
longer starts behind a placeholder disposer that subscribe can never call.

* test(liveshare): cover the owner host and gateway glue

The host half gains the direct specs its uncovered branches named: the
stored credential file (absent, corrupt, unbound, empty facts, deletion),
the viewer principal comparison, the share Remote's audit delegation, the
registration handshake's plain-socket scheme, the relay refusal status
reader, the JSON body reader, the fork policy's mode helpers and empty
prefix, and the fork guard's unreachable-context refusal. The worker entry
is covered through a mocked worker module, so its boot decision runs
without a spawned process.

The served-page resource answer no longer carries a `string` body: both
call sites hand it bytes, and the string branch could not run.

* test(liveshare): cover owner state, authorization, and the header action

Pins the owner state file's restore and refusal rules against real files
(every optional fact, every invalid record, the owed Relay rows, and a
failed write), the authorization transaction's four bindings and its
single-use state, the declaration surface's stable package order and
repeated withdrawal, and the Session-header action that opens the share
dialog and switches it to management.

The owner state write chain no longer catches a rejection it cannot
observe: the map holds only promises whose failures were already absorbed.

* test(liveshare): close the owner and declaration surface gaps

Pins the authorization URL the browser opens and the production clock and
lifetime a transaction gets when the host passes neither, the declaration
surface's stable package order and repeated withdrawal, the owner state
file's restore and refusal rules against real files, and the Session-header
action's open, manage, and close paths.

The declaration disposer's two optional lookups are annotated instead of
tested: this effect registers each accepted name once, so its own disposer
cannot reach a missing entry, and the effect disposers are idempotent, so a
second withdrawal never re-enters that loop.

* test(liveshare): satisfy the lint and compiler gates in the new specs

The refusal double takes the empty details its code declares, the waits
return nothing instead of a void expression, and the IPC entry reads the
channel through a computed access so the lint rule does not read it as an
unbound method reference.

* test(liveshare): cover the gateway worker and tunnel supervision

* test(liveshare): cover the owner, relay, fork, and viewer host modules

* refactor(liveshare): keep the coverage hooks behavior-preserving

* test(liveshare): fix the typed listener and endpoint fixtures

* test(liveshare): cover the owner login, share manager, and viewer plugin

* test(liveshare): cover the share dialog and host fork wiring

* test(liveshare): satisfy the contract lint in the gateway worker spec

* chore(coverage): drop the liveshare lane exemption

* feat(liveshare): ship the remaining shipped-language dictionaries

The locale parity gate requires every shipped language beside each object-form
dictionary, so the Liveshare browser plugin now carries Japanese, Spanish,
French, German, Russian, and Brazilian Portuguese alongside `zh` and `en`, and
registers all eight.

Each dictionary is typed `Record<keyof typeof zh, string>` so a missing or extra
key fails the build, and named placeholders, slash commands, and API field names
stay verbatim in every language.

* docs(liveshare): document the host config and two exported host contracts

`verify-export-jsdoc` requires prose on every exported declaration: `Config`
states what a deployment sets and why `relayUrl` has no default,
`requestedBoundary` states how a fork request names its prefix and what an
unnamed one becomes, and `OpenStream` states what the reader tracks and when it
releases the stream.

* chore(docs): regenerate the liveshare catalogs and acknowledgement after rebasing

Rebasing the host layer onto current master left the generated artifacts at the
new base's state, because each conflict was resolved to master's side rather
than merged by hand. Recompute them from the rebased tree with the repository
generators: `gen-config-catalog`, `gen-doc-graphs`, `gen-persistence-catalog`,
`gen-client-catalog`, `gen-tool-catalog`, and `verify-persistence-formats
--write`. `gen-module-graph`, `gen-cordis-catalog`, and
`gen-official-bundle-catalog` computed no change.

The LiveShare persistence acknowledgement predated master's later changes to the
four user-message source roots and stored the inventory in a non-canonical
layout, so `verify-persistence-changes` could not read it. Re-record it with
`persistence-changes --update ... --decision same-version`, which chains it onto
master's current tips and rewrites the snapshot, the bilingual declaration, and
their pairing record. Re-record the ui-plugin-manager README pair, whose
LiveShare paragraph now matches on both sides.

* test(liveshare): read attachments by the complete image reference

Current master sends `session/attachment` the complete image reference the
conversation folded from the log and answers with the stored reference plus
bytes, instead of accepting a bare `attachmentId`. The viewer fixture still
read the old field, so every assembled-page image read failed and the
viewer-shell and viewer-global-privacy artifact tests timed out waiting for the
delivered image. Follow the new request field and keep verifying by id against
the Session's log.

* test(liveshare): pass the complete reference in the owner attachment probe

`session/attachment` now takes the complete image reference, so the admission
spec's owner-side probe sends the fixture's reference instead of a bare id.

* fix(liveshare): classify the current Client endpoints for the share-viewer policy

Master added session read/observation endpoints to the Client's Remote map, and
the share-viewer policy's reader role is the only grant they can receive. Name
the six read endpoints in the reader role and in the classification table, keep
the endpoints this Client assembly cannot name out of the table (they stay
owner-only by default), and extend the served-page contract plus an admission
test so a hidden Session is still refused.

* fix(liveshare): await and type the turn-requester projection

Register the fold before publishing the policy and source layer that read
it, surface a refused registration instead of leaving it unobserved,
reset the retained handle when the projection service reloads, and type
the boundary and execution reads from Agent and TurnBoundaryProjection.

* fix(liveshare): withdraw only the fork children a revocation saw

Apply a withdrawal to the entries the registry file holds when it
commits, leave a child recorded afterwards live across restarts, and
retry a failed withdrawal without replaying it over a newer child.

* fix(liveshare): refuse a fork that names no Session

Answer 400 with a named code before dispatch, so a malformed fork body
cannot reach the owner's Gateway unrecorded.

* fix(liveshare): stop injecting a child id into fork requests

SessionForkRequest mints the child id, so the injected field was ignored.
Forward the caller's envelope and rely on the recorded operation for a
retry; document the crash window that needs a caller-supplied child id.

* fix(liveshare): classify and dispatch one normalized path

Use the same pathname plus query for the endpoint decision and the owner
dispatch, and parse with URL.canParse instead of an empty catch.

* docs(liveshare): record the two-tier driving model

Rewrite the implemented note's stale tier, turn-carrier, upload, and
retry facts, add the superseding decision note, and keep the subsystem
page and package READMEs aligned.

* fix(liveshare): publish only the exported type module from lib/types

The package published every tsc-emitted lib/types/**/*.js file, so the client
JavaScript that imports .module.css files entered the tarball without those
stylesheets. Retain only lib/types/share-api-types.js, the one runtime file
the ./types export names, alongside the declaration tree.

Teach the publication allowlist that this package's emitted tree is completed
by its exact runtime file rather than the whole glob, and pin both the accepted
and rejected payloads.

* test(liveshare): mock the generated remote for assembled viewer pages

The assembled viewer specs load the client entry, which statically imports the
generated ./remote contribution. A clean checkout has no lib/ build, and Vite
resolves a static import while transforming the entry, before vi.mock can
answer it, so the jsdom suites failed to import the page roster.

Mock the contribution in each assembly spec, matching the owner-side face
tests, and provide a resolvable test stand-in whenever the artifact is absent.

* fix(liveshare): guard hover styles and draw the viewer banner hairline

Wrap the owner action, dialog actions, and viewer wordmark hover rules in
@media (hover: hover) so touch devices do not latch the hover treatment, and
draw the viewer status banner's neutral border at the 0.5px hairline width.

* refactor(liveshare): name the owner gateway directory

The owner-side Relay connection process is LiveShare's own gateway, not the
Host API gateway; move it to src/owner-gateway so the published entry
lib/owner-gateway/cli.js and the module headers say which gateway they mean.
The protocol vocabulary (frames, tunnel mode) keeps the word gateway.

* docs(liveshare): merge the tier and visibility notes into the owner note

* docs(liveshare): call the Relay the Liveshare server

* docs(liveshare): name the Liveshare server in the share-management JSDoc

* docs(liveshare): document that new shares are organization links

The shipped Share dialog sends neither accessMode nor allowedSubjectIds, so
every share it creates is open. The agent note, both package READMEs, and
CreateShareRequest's JSDoc now say that selected/approval shares are legacy
stored records that keep their ACL and access summary, that the UI has no
creation entry for them, and that the create fields remain for those records
and direct Remote callers.

* refactor(liveshare): drop the configurable owner display name

The reader-facing owner name now always resolves from the Relay-verified
credential: its display name, otherwise the subject label, otherwise the
Relay's host name. Config.ownerDisplayName and the namedOwner dependency
only fed the removed override, so delete both and the test configuration
that set the field.

* docs(liveshare): read the owner name only from the verified credential

Remove the LIVESHARE_OWNER_NAME row and its cordis patch mapping, and say
in both READMEs that a reader-facing share name comes from the
Relay-verified credential rather than local configuration. The
LIVESHARE_OWNER_DISPLAY_NAME legacy identity label stays.

* feat(liveshare): save the relay address from the share dialog

The public address is the owner's runtime choice, not deployment configuration:
the host loads without one, the dialog saves it into the owner state, and new
shares publish there until the owner saves another or clears it. A host with no
saved address contacts no server and its sharing surfaces state that.

* docs(liveshare): describe the address saved in the share dialog

The README, subsystem page, generated API reference, and their bilingual
pairing now state the dialog-owned address and the no-address host state.

* refactor(liveshare): sandbox a shared session instead of attributing turns

Retire per-turn origin isolation: delete the turn-requester fold, the
turn-boundary derivation, and every per-turn verdict, and make the source
access layer a session-level sandbox.

While any share over a Session is active the layer's guard permits only the
confined read it mounted (plus the bounded write when some share may write);
every turn of that Session, the owner's own included, runs through them and
nothing else. The layer is released once no active share is left, which
restores the Session's own tools, and the read-only layer is re-installed
when the last write share narrows to view. Each operation re-reads the
shares' current Relay grants and checks the resolved target against the
granted root.

A viewer's session/cancel now travels the standard Gateway dispatch under
the source-write role instead of a host-side ownership comparison, and a
share that stops, expires, or loses admission stops whatever turn its
Session is running.

Behavior changes:
- The owner is confined too: while a share is active the owner's own turns
  can only use the confined read (and bounded write), and the full tool set
  returns when every share stops or expires.
- Cancellation has no attribution: a viewer's stop cancels the shared
  Session's running turn, including one the owner started.

* test(liveshare): exercise the session sandbox and gateway cancel dispatch

Delete the specs that asserted per-turn attribution (turn-requester, source
cancel ownership, source execution turn, source turn revocation) and the test
fixtures only they used. Cover the behavior that replaced them: every turn of
a shared Session is confined while the layer is mounted, the layer is released
when no share is left, a write-to-view narrowing re-installs the read-only
layer, a share ending stops the running turn, and a viewer's cancel and prompt
both travel the standard Gateway dispatch as the reader's Peer.

The pre-step admission test now covers the messages' own principals, and the
projection double is gone with the projection it stood in for.

* docs(liveshare): document the session sandbox and retiring turn origin isolation

Update the package READMEs, the LiveShare subsystem, the share-viewer
principal persistence record, and the merged Agent Note: remove the
turn-requester fold, turn-boundary derivation, pinned source cancel, and
per-turn origin prose, and state the shipped facts instead — a Session-level
sandbox that also confines the owner while a share is active, a standard
Gateway cancel with no ownership comparison, and the pre-step admission check
as the principal's only consumer. Re-record the bilingual pairing records.

* refactor(liveshare): extract share dialog relay and identity hooks

Move the Relay address read/save/draft state and its connectivity and
permission checks into useShareRelayAddress, and the owner identity read
plus the one sign-in attempt into useOwnerIdentityLogin. ShareDialog
keeps the step guide and passes the identity facts saving an address
needs, so neither hook reaches into the other.

* refactor(liveshare): extract the share dialog create form

Move the session permission, fork switch, lifetime, and the create
action into useShareCreateForm and ShareCreateForm. The dialog keeps the
link, error, and created-record adoption it already owned, and the
shared choice builders live in share-options so the edit panel can use
the same session-mode list.

* refactor(liveshare): extract the share edit panel and tier picker

Move the stored share's permission, fork switch, endpoint tier, and the
one save into useShareEdit and ShareEditPanel, with the tier expansion and
summary in ShareCapabilitiesPicker. ShareDialog now composes the relay,
identity, create, and edit hooks and keeps only the link, advanced help,
and close/manage surface.

* fix(liveshare): take share dialog hook options as named parameters

The exported hooks documented an `options` parameter while destructuring it
in their signatures, so their JSDoc could not name the binding.

* chore(docs): regenerate graphs, catalogs, and pairing after rebasing on master

The rebase merged this branch's liveshare entries with master's forge entries;
the generated English sources and their bilingual pairing records now reflect
both.

* feat(liveshare): open the shared read and write tool surface

The source-session sandbox and each fork child's limit now admit one shared surface: read, read_image, glob, grep, present, todo_write, skill, web_search, and web_fetch at the view tier, plus edit and write at the write tier.

Every path a call declares is resolved and checked against the granted root before the deployment tool receives its canonical form; a pathless search uses the Session's working directory. Tools with no local file effect keep the deployment's own definition, which the guard vouches for by identity. Everything else, including bash, pwsh, cua, the plugin manager, and the schedule tools, stays refused.

* test(liveshare): cover the shared tool surface

Drive the view and write surfaces through the source sandbox and a fork child's limit: canonical paths, symlink and relative escapes, pathless searches, deliveries, the no-effect tools, both mutation tools, and every capability the surface does not name. Registration counts in the host wiring now read the shared table instead of one confined read.

* docs(liveshare): describe the shared tool surface

The package README, the subsystem reference, and the owner note now state the admitted tools per tier, how each declared path is checked, and that the no-effect tools keep the deployment's definition. Re-record the bilingual pairing and the generated event graph for the new fs/edit-intent consumer.

* feat(liveshare): confine viewer turns and admit PTC run_code

Owner turns of a shared Session keep the deployment's native tool chain. The
source layer now decides per turn, from the admitted input's share-viewer
principals, whether the turn is confined; agent/pre-step records that answer
in Host memory only, and the guard plus every confined replacement read it.
A turn the owner starts runs the deployment's tools unchanged, an owner's
steering input inside a viewer's turn stays confined, and stopping every
share still disposes the layer.

Both tiers also admit the PTC transport run_code. A non-empty
sandbox_permissions is refused; every nested tools.* call is a separate
execution through the same guard and confined replacements, so tools.bash
stays refused and read/edit/glob/grep keep their grantedRoot checks. Before
the runtime starts a program, run_code dispatches the new sandbox/policy
waterfall with the Session-resolved policy; the source layer caps it to the
share tier (read-only for view, workspace-write at most for write, never
danger-full-access) and drops the deployment's extra writable roots. A fork
child's installation applies the same cap from its live tier.

Verification: pnpm run typecheck, oxlint on the changed files, and the
focused liveshare specs for the turn marker, owner/viewer turns, run_code
admission and nested calls, and the policy caps. Local commits only; not
pushed.

* refactor(liveshare): drop the uncommitted-prefix fork state

The host never reads the source Session log, so it cannot know a completed
sequence. The forking command validates the requested boundary against its own
committed history instead, which makes the local boundary refusal unreachable.

* feat(ui-chat): export the official user renderer and shared-session copy

* feat(liveshare): mark shared-session reader messages through the chat node seat

* test(liveshare): cover shared-session attribution and the reused chat node seat

* fix(liveshare): leave steering rows to Chat

Only the message that opens a conversation carries the reader attribution;
steering is a turn-internal insertion, so its official Chat cell is untouched.
The wrapper now shadows the user cell alone.

* feat(plugin-manager): hide default bundles through their manifest

The LiveShare bundle now declares `dsh.bundle.listedByDefault: false` and the
page applies one manifest rule to every bundle: a default-hidden bundle is
listed only when the profile enables it, the installation carries it selected,
or one of its declared rows runs. The client-side name set and its special
details-by-id fallback are gone.

* docs(sandbox): document the per-program sandbox policy extension

The new `sandbox/policy` waterfall owns one event scope, the generated event
graph and subsystem reference now list it, and the catalog classifies its
payload type.

* chore(docs): regenerate the graphs and pairing after rebasing on master

The rebased branch now shares master's forge and sandbox service entries; the
generated English graphs and their Chinese counterparts are re-recorded.

* test(web): keep the plugin lists contiguous around the LiveShare card

The recorded accessible tree has no blank line between the last experimental
card and the installed-plugins heading.

* test(typert): expect the PTC sandbox policy event in the executor catalog

The new waterfall is part of the executor's model events.

* fix(liveshare): name the PTC transport without the unclassified tools export

The dependency policy does not classify @deepseek-ai/dsh-tools' RUN_CODE_NAME
as a safe host export. Liveshare now owns the reserved name locally, pinned to
the canonical constant by its test suite.

* fix(liveshare): close the coverage gaps in the owner-turn and PTC paths

The asynchronous policy resolver is covered by the executor tests; the
share-host guard covers unattributable executions and same-turn narrowing;
the owner's edit path exercises the native pass-through.

* test(web): align plugin goldens with the manifest visibility rule

The default profile no longer lists LiveShare before its rows run, the refresh
count includes the forge bundle master added, and the creation recommendation
skill catalog records the new bundle entry.

* fix(ptc): keep the disposed check lint-clean after policy resolution

The narrowed property read became an always-false conditional; the helper
method keeps the runtime check without the narrowing.

* test(liveshare): cover a relay probe stopped by host disposal

The probe checks the disposed flag after its fetch; a disposal landing during the
request now exercises that early return.

* fix(liveshare): encode identifiers without the base64url Buffer name

Packed worker deployments run a Buffer polyfill that only names standard
base64; the new helper produces the same unpadded base64url text from it. The
packed preview's generic bundle toggles skip the declaration-first Liveshare
bundle, which the ordinary Web scaffold covers.

* test(liveshare): build worker command fixtures from real file URLs

`file:///pkg/...` has no drive letter, so Windows `fileURLToPath` refuses it;
the launch-command test now derives both fixtures with pathToFileURL.

* feat(ui-chat): offer a user-origin seat above user rows

Chat now declares a session-scoped `conversation.chat.user.origin` list seat and passes the flow's renderer to the official user row through `ChatNodeOwnerProps.renderUserOrigin`. The flow supplies the callback only while the seat has an entry, and the official renderer calls it for `user` nodes alone, so steering keeps its DOM. Entries receive the durable message source and own their markup and copy; ui-chat ships none, and its shared-session dictionary entry moves back to the feature that writes it.

* feat(liveshare): attribute shared-session messages through Chat's origin seat

The client registers one `conversation.chat.user.origin` list entry on both faces instead of shadowing Chat's user node renderer. The component recognizes the durable share-viewer principal and states the Relay-asserted subject from the package's own `inputOrigin.sharedSession` copy in all eight dictionaries; owner-authored, malformed, and steering rows render nothing. The ui-chat runtime external and its client-inject edge are gone: the remaining ui-chat import is type-only, and `slots.inject` makes the registration independent of declaration order.

* chore(client): record the user-origin seat in the slot catalog

Regenerate the model-facing client slot catalog for `conversation.chat.user.origin`: the new list seat, its owner props, its declaring flow entry, and the Liveshare occupant. The subsystem slot tree follows the same declaration.

* chore(liveshare): publish the types entry as declarations only

`./types` no longer declares a runtime target into the tsc-emitted tree, so the
package no longer publishes `lib/types/share-api-types.js` and the workspace
constraint check no longer needs a package-name exception.

* docs(ui-chat): name the message-source decoration

The prose uses the exact field it decorates instead of the abstract term the
repository's concrete-terms gate rejects.

* fix(liveshare): classify master's tool-results stream after rebasing

Master added the `toolResults/watch` Remote endpoint and bumped the workspace to
0.2.1-rc.4; the viewer endpoint table and the read role now cover the stream,
and the package version follows the root.

* refactor(ptc): keep sandbox policy dispatch synchronous

The policy cap is a pure, cheap computation, so the waterfall should not force
every confined program through an await before the call is registered. The
event now returns the policy directly: no admission window between reading the
mode and registering the call, no async mode resolver, and no fallback branch
in PtcCalls. The event can still be dispatched twice for one Agent-owned
program; its contract names the synchronous, idempotent listener requirement
and tests lock both dispatch counts.

* docs(ptc): state the synchronous sandbox-policy contract

The README now names the synchronous waterfall and the two policy resolutions
an Agent-owned program performs, so readers know listeners must be synchronous,
pure, idempotent, and return the same cap each time.

* fix(liveshare): let the declaration entry win the fallback race

A live bundle activation can start the share host before the separate
declaration entry's service is visible in the host context. The host now gives
that entry one task turn before providing its own fallback surface, so both
rows no longer register `liveshareViewerDeclarations` twice.

* chore(liveshare): drop the now-unused require-await suppression

The deferred fallback makes apply genuinely await, so the file no longer needs
the suppression it carried for a synchronous throw.

* fix(liveshare): read the owner Remote namespace after mounting it

The client face accessed `remote.liveshareShares` on its own context before
that namespace existed, which the Remote carrier refuses without an inject
entry. The header action now registers inside a child scope injected after the
carrier mounts, and the face tests provide the mounted namespace.

* feat(liveshare): simplify the owner share dialog

Saving the relay address now checks it in the same action, and the numbered
step guide is gone. Sign-in is a single control above the share settings, and
every setting below it stays disabled until the owner signs in. Fork wording
and the read-only permission hint now state the actual permissions.

* feat(liveshare): save the relay address on blur

The address field no longer needs a Save button: leaving the field (or pressing
Enter) stores it and probes the address it put in force. Chinese copy now uses
观看者 consistently instead of mixing viewer and 观看者.

* chore(ptc): restore the run signature formatting

The synchronous policy refactor left only a line-wrap difference in this file;
the signature returns to its original one-line form.

* feat(liveshare): configure plugin sharing when creating a share

The create form now carries the same share-wide plugin-endpoint tier as the
stored-share editor and writes it immediately after the host creates the
share. The Advanced disclosure only renders for an existing share, where it
has stored-share details to show.

* fix(liveshare): show the owner name on shared-message attribution

The durable source names the reader by subject id, so the line showed the opaque
id even when the reader was the owner. The client now remembers the verified
owner identity it reads on mount, and the attribution uses that display name for
the matching subject while keeping the persisted id for every other reader.

* feat(liveshare): use the viewer's Relay name in shared-message attribution

The viewer context already carries the reader's own display name; reading it
lets a viewer's messages show their name instead of the opaque subject id,
while a reader with no reported name keeps the durable id.

* feat(liveshare): persist the viewer display name in message attribution

The Relay asserts a display name for the viewer it forwards. Copying that name
into the durable share-viewer principal lets the owner and every reader show
who sent a message, instead of only the current reader or an opaque subject id.
The name is attribution data only and stays out of authorization and model
requests; old logs and readers without it keep showing the subject id.

* feat(liveshare): share the permission fields between create and edit

Creating and managing a share now render the same session permission, fork
switch, and plugin-endpoint fields, and the stored-share view states its
lifetime and link scope inline instead of behind an empty Advanced disclosure.
The first version publishes only the never-expiring lifetime.

* feat(liveshare): edit a share lifetime and link scope after creation

`updatePermissions` now accepts a new deadline (null means never) and the
open/selected link scope. The shared permission fields expose both while
creating and editing; an existing share can be switched to the open link or
cleared back to never, and selected shares accept provider subject ids.
LiveShare prose now calls every fork product a 分叉会话.

* test(liveshare): remove duplicate lint suppression

* fix(liveshare): keep row labels on one line

* fix(liveshare): clear the static catalog and unknown-cast gates

The owner-display test no longer casts through unknown, and the generated
Cordis runtime catalog now includes the extended permission request type.

* test(liveshare): cover the unchanged update and subject-order branches

* test(liveshare): cover the unchanged restored-access branch

* fix(liveshare): re-probe a stale owner credential

* refactor(liveshare): remove the unfinished selected-colleague scope

The share dialog no longer offers or edits a selected-colleague link scope;
new shares use the organization link and stored legacy scopes stay read-only.
The create form's lifetime is the single never-expiring row, and the host
identity read re-probes a stale credential.

* feat(liveshare): simplify the share manager and add plugin sharing

The global manager now lists Session titles and opens the matching Session's
share dialog, which names the Session it edits and exposes one plugin-sharing
switch. Shares can still be listed without any plugin declaring endpoints, and
the switch states that absence.

* fix(liveshare): open the selected share from the manager

The manager's Manage action now opens that Session's share dialog directly,
and binds workspace navigation when the page has it. The rows use one grid so
titles, online state, and actions line up, and the footer offers Cancel next to
Done.

* refactor(liveshare): elect shared-session attribution through a chain seat

Chat now declares `conversation.chat.user.before` as a chain seat whose owner
props carry the durable source and row kind. Liveshare's selector elects only
share-viewer user rows, so the component no longer probes or returns null, and
the official user row stays unchanged. The manager aligns its columns, adds a
per-share cancellation action, and no longer offers a footer cancel that meant
the window rather than a share.

* refactor(liveshare): drop PTC sharing from this change

The share surface now admits only the native read/write tools. PTC
run_code admission, its sandbox-policy cap, and the executor-side policy
extension move to a separate change, so this PR keeps non-PTC sharing focused.
Core PTC files, sandbox docs, and generated catalogs return to master.

* docs(liveshare): fold the viewer display name into the principal record

The optional displayName is part of the share-viewer principal, so its
persistence acknowledgement now lives in the 2026-10-13 principal record
instead of a separate 2026-10-27 record.

* refactor(liveshare): render user attribution through its own chain slot

* refactor(liveshare): keep one user message renderer for the attribution seat

* refactor(liveshare): render the attribution seat inside the user bubble

* docs(liveshare): regenerate the event graph pages

* test(liveshare): close the endpoint control coverage gaps

Cover the endpoint tier control's empty-offer return and drop the unused expiry choice list.

* chore(docs): regenerate the persistence catalogs after rebasing onto master

* chore(liveshare): carry the 0.2.2-alpha.1 release version
2026-10-28 01:44:19 +08:00

1927 lines
85 KiB
TypeScript

/**
* Generate the relationship layer above the module, Cordis, and tool catalogs.
* Enumerable facts come from source; hybrid graphs add manifests for policy the
* source cannot infer, while curated graphs explain flow and ownership.
* `--check` verifies the generated set.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import ts from 'typescript'
import { projectCordisCatalog } from '@deepseek-ai/dsh-typert-generator'
import { CORDIS_CATALOG_POLICY } from './gen-cordis-catalog.ts'
import type { EventEntry, ServiceEntry } from '@deepseek-ai/dsh-typert-generator'
import {
collectPackageGraph,
escapeMermaidLabel as escLabel,
graphNodeId as nodeId,
type PackageGraphNode,
} from './package-graph.ts'
import { rewriteTranslationLinkLocales } from './translation-links.ts'
import {
generatedRegions,
parseTranslationPairingManifest,
renderGeneratedRegion,
spliceGeneratedRegion,
translationPairSourcePredicate,
} from './translation-pairing.ts'
import { TypeScriptProject } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
type Pkg = PackageGraphNode
interface GraphDoc {
rel: string
content: string
}
interface ServiceRole {
key: string
pkg: string
title: string
mode: 'core' | 'seam' | 'bundle' | 'service'
implementations?: string[]
consumers?: string[]
companions?: string[]
note: string
}
interface ExamplePlugin {
id: string
name: string
}
interface EventRelation {
dispatchers: Map<string, Set<string>>
listeners: Set<string>
}
/** One scanned package source file and its owning package short name. */
export interface PackageSource {
/** Repository-relative path. */
rel: string
/** Package short name from the `packages/<group>/<pkg>/src` path. */
pkg: string
/** The bound program source file. */
sourceFile: ts.SourceFile
}
type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service'
const GROUP_ORDER = [
'util',
'attachment',
'document',
'llm',
'core',
'typert',
'goal',
'experimental',
'process',
'bash',
'pty',
'sandbox',
'ssh',
'fs',
'skill',
'compact',
'subagent',
'tasks',
'workflow',
'web',
'webhook',
'spill',
'todo',
'plan',
'cordis',
'hooks',
'session-persistence',
'session-query',
'session-title',
'telemetry',
'storage',
'workspace',
'support',
'acp',
'ui',
]
const SERVICE_ROLES: ServiceRole[] = [
{
key: 'actionCards',
pkg: 'action-cards',
title: 'Optional action cards',
mode: 'service',
consumers: ['creation-recommendations', 'client-ui-action-cards'],
note: 'Owns the card tool, card state, user actions and status notices; card kinds register their actions and handlers.',
},
{
key: 'pluginRecommendations',
pkg: 'plugin-recommendations',
title: 'Explicit-need plugin recommendations',
mode: 'service',
consumers: ['client-ui-plugin-recommendations'],
note: 'Reads the current catalog text of an official plugin card and enables the plugin through the profile plugin manager after a click.',
},
{
key: 'headlessStartup',
pkg: 'headless',
title: 'Headless invocation options',
mode: 'core',
consumers: ['headless'],
note: 'Supplies parsed task and run options to the headless runner and its sandbox-policy configuration.',
},
{
key: 'cotTranslation',
pkg: 'experimental-client-ui-cot-translation',
title: 'Reasoning translation Remote',
mode: 'service',
note: 'Serves reasoning translations, reuses saved results, and joins ordinary Session activation for uncached inactive Sessions.',
},
{
key: 'translator',
pkg: 'experimental-translator',
title: 'Anonymous text translation',
mode: 'service',
consumers: ['experimental-client-ui-cot-translation'],
note: 'Resolves Google or Bing requests and retains shared experimental records through the active Session writer.',
},
{
key: 'hmr',
pkg: 'hmr',
title: 'Serialized module and configuration reloads',
mode: 'core',
consumers: ['app-boot'],
note: 'Owns module and exact configuration watchers; application mutations share its queue and automatic reloads await the application file lock.',
},
{
key: 'pluginRegistryProbe',
pkg: 'client-ui-plugin-manager',
title: 'Host registry response comparison',
mode: 'core',
consumers: ['client-ui-plugin-manager'],
note: 'Races public registry responses on the Host; the Client owns the initial registry recommendation.',
},
{
key: 'pluginManager',
pkg: 'plugin-manager',
title: 'Current-profile plugin and bundle management',
mode: 'core',
consumers: ['plugin-manager', 'ui-settings-plugin-inventory'],
note: 'Shares profile package operations with the CLI and reports persisted and running state to Web and agent callers.',
},
{
key: 'pluginMarket',
pkg: 'plugin-market',
title: 'Plugin market index reader',
mode: 'core',
consumers: ['client-ui-plugin-market', 'plugin-market'],
note: 'Fetches and validates the index sources the user configures; installation goes through the plugin manager.',
},
{
key: 'profileContext',
pkg: 'app-boot',
title: 'Launcher-owned profile data',
mode: 'core',
consumers: ['plugin-manager'],
note: 'The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr.',
},
{
key: 'connection',
pkg: 'client-connection',
title: 'Authenticated browser transport',
mode: 'core',
consumers: ['api-gateway', 'host-frontend-static'],
note: 'Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams.',
},
{
key: 'forge',
pkg: 'experimental-forge',
title: 'Code host change requests',
mode: 'seam',
implementations: ['experimental-forge-github'],
consumers: ['experimental-forge-session', 'experimental-client-ui-forge'],
note: 'Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop.',
},
{
key: 'forgeSession',
pkg: 'experimental-forge-session',
title: 'Session change request bindings',
mode: 'service',
consumers: ['experimental-client-ui-forge'],
note: 'A Session projection derives bindings from shell results and binding records and publishes their URLs without status to clients; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry.',
},
{
key: 'forgePanel',
pkg: 'experimental-client-ui-forge',
title: 'Forge panel Remote',
mode: 'service',
note: 'Streams one panel state per followed Session: a read-only Git inspection in the Session sandbox, the recognized code host and its sign-in, and the bound change requests with polled status.',
},
{
key: 'worktrees',
pkg: 'experimental-worktree',
title: 'Git worktree creation',
mode: 'seam',
implementations: ['experimental-worktree'],
consumers: ['experimental-tool-worktree'],
note: 'Explicit experimental creation from a pinned local commit. Existing write permissions govern checkout and shared Git metadata; the working-directory service owns the resulting Session directory.',
},
{
key: 'workingDirectory',
pkg: 'working-directory',
title: 'Session working directory',
mode: 'seam',
implementations: ['working-directory'],
consumers: ['tool-working-directory', 'tool-fs', 'tool-bash', 'tool-pwsh', 'subagent', 'sdk-jsonrpc-server'],
note: 'One Session projection owns the effective execution directory. The filesystem validates changes; user context reports them while original metadata and write grants stay fixed.',
},
{
key: 'mcpResources',
pkg: 'mcp-resources',
title: 'Scoped MCP resource access',
mode: 'seam',
implementations: ['mcp-client'],
consumers: ['mcp-resources'],
note: 'Connection-owned providers serve shared resource tools in the calling agent scope.',
},
{
key: 'browserUse',
pkg: 'browser-use',
title: 'Browser-use provider registration',
mode: 'seam',
implementations: ['experimental-browser-use-playwright-mcp', 'experimental-browser-use-chrome-devtools-mcp', 'experimental-browser-use-stagehand-native'],
consumers: ['experimental-browser-use-playwright-mcp', 'experimental-browser-use-chrome-devtools-mcp', 'experimental-browser-use-stagehand-native'],
note: 'One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API.',
},
{
key: 'computerUse',
pkg: 'computer-use',
title: 'Computer-use provider registration',
mode: 'seam',
implementations: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'],
consumers: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'],
note: 'One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock.',
},
{
key: 'cuaAccess', pkg: 'cua', title: 'Computer Use permission controls',
mode: 'core', consumers: ['cua'],
note: 'The native provider owns application consent and cancellation; the optional Client page queries and changes those decisions through its Remote.',
},
{
key: 'officeToPdf', pkg: 'office-to-pdf', title: 'Office to PDF conversion',
mode: 'core', consumers: ['client-ui-sidebar-documentpreview'],
note: 'Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared.',
},
{
key: 'attachments',
pkg: 'attachment',
title: 'Durable binary attachment storage',
mode: 'seam',
implementations: ['attachment-local'],
consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'],
note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.',
},
{
key: 'fileUploads',
pkg: 'client-file-upload',
title: 'Agent-scoped staged file uploads',
mode: 'core',
consumers: ['api-session-controller'],
note: 'Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions.',
},
{
key: 'llm',
pkg: 'llm',
title: 'LLM adapter registry',
mode: 'seam',
implementations: ['llm-deepseek', 'llm-pi-ai', 'llm-replay'],
consumers: ['agent-loop', 'compaction-basic'],
note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.',
},
{
key: 'deepseekLlmApiExtensions',
pkg: 'deepseek-llm-api-extensions',
title: 'Official DeepSeek request extensions',
mode: 'seam',
implementations: ['session-log-deepseek', 'plugin-package-inventory-deepseek'],
consumers: ['llm-deepseek'],
note: 'Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance.',
},
{
key: 'tokenMeter',
pkg: 'token-meter',
title: 'Replay token measurement',
mode: 'core',
consumers: ['compaction-basic'],
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
},
{
key: 'toolResultPruner',
pkg: 'compaction-tool-result-pruner',
title: 'Model-free tool-result pruning',
mode: 'core',
consumers: ['compaction-basic'],
note: 'Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction.',
},
{
key: 'sessions',
pkg: 'session',
title: 'In-memory session store',
mode: 'core',
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent', 'message-feedback'],
note: 'Owns append-only Session instances and emits the durable session event feed.',
},
{
key: 'speechController',
pkg: 'experimental-api-speech-to-text',
title: 'Experimental transcription Remote',
mode: 'core',
note: 'Validates bounded browser audio before provider dispatch.',
},
{
key: 'sessionController',
pkg: 'api-session-controller',
title: 'Host Session Remote controller',
mode: 'core',
note: 'Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy.',
},
{
key: 'sessionFileReferences',
pkg: 'api-session-controller',
title: 'Session-addressed file-reference Remote adapter',
mode: 'core',
note: 'Delegates file-reference discovery through the Session Controller\'s established Agent lookup policy.',
},
{
key: 'sessionSkillCatalog',
pkg: 'api-session-controller',
title: 'Session-addressed skill Remote adapter',
mode: 'core',
note: 'Lists the Session composition\'s user-invocable skills without activating a cold Agent.',
},
{
key: 'jobController',
pkg: 'api-job-controller',
title: 'Host job Remote controller',
mode: 'core',
note: 'Streams one background job\'s observation record over the generated Remote namespace; the roster stays on the session control stream.',
},
{
key: 'credentialsController',
pkg: 'api-settings-controller',
title: 'Host credential-surface Remote controller',
mode: 'core',
note: 'Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition.',
},
{
key: 'settingsController',
pkg: 'api-settings-controller',
title: 'Host settings-surface Remote controller',
mode: 'core',
note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.',
},
{
key: 'workspaceFiles',
pkg: 'api-workspace-files',
title: 'Host workspace file Remote service',
mode: 'core',
note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.',
},
{
key: 'workspaceChanges',
pkg: 'workspace-changes',
title: 'Host per-turn changed-file summaries',
mode: 'core',
note: 'Serves the summary each workspace/changes event announced and each listed file\'s turn-start and turn-end comparison, by Session and event sequence, until that Session is disposed; the log carries only the turn.',
},
{
key: 'terminalController',
pkg: 'api-terminal-controller',
title: 'Session interactive terminal Remote controller',
mode: 'core',
note: 'Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport.',
},
{
key: 'workspaceController',
pkg: 'api-workspace-controller',
title: 'Host Workspace Remote controller',
mode: 'core',
note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.',
},
{
key: 'directoryPickerController',
pkg: 'api-workspace-controller',
title: 'Host directory-picking Remote controller',
mode: 'core',
note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.',
},
{
key: 'typert',
pkg: 'typert-registry',
title: 'Runtime type registry',
mode: 'core',
consumers: ['typert-loader', 'api-gateway'],
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges.',
},
{
key: 'typertGateway',
pkg: 'api-gateway',
title: 'Typert Host invocation gateway',
mode: 'core',
note: 'Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier.',
},
{
key: 'remoteAccess',
pkg: 'api-remote-access',
title: 'Remote method access control',
mode: 'core',
consumers: ['api-gateway', 'api-session-controller', 'api-workspace-controller'],
note: 'Decides which Remote methods a non-owner Peer may call through business-registered role-based policies, filters list results, and streams each Peer access snapshot.',
},
{
key: 'sessionPersistence',
pkg: 'session-persistence',
title: 'Durable session persistence seam',
mode: 'seam',
implementations: ['session-persistence-jsonl'],
consumers: ['agent-loop', 'tool-bash', 'hooks-claude-code', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'],
note: 'The JSONL backend persists the SessionEvent vocabulary as one artifact per Session.',
},
{
key: 'configEditor',
pkg: 'config-editor',
title: 'Profile configuration edits',
mode: 'core',
consumers: ['settings', 'agent-default-model'],
note: 'Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries.',
},
{
key: 'settings',
pkg: 'settings',
title: 'Plugin configuration forms',
mode: 'core',
consumers: ['api-settings-controller'],
note: 'Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references.',
},
{
key: 'subagentModelSelection',
pkg: 'tool-subagent',
title: 'Subagent model-selection preference',
mode: 'core',
consumers: ['tool-subagent'],
note: 'Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session.',
},
{
key: 'credentials',
pkg: 'credentials',
title: 'Credential seam',
mode: 'seam',
implementations: ['credentials-local'],
consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'],
note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage.',
},
{
key: 'deepseekAccount',
pkg: 'deepseek-account',
title: 'DeepSeek account',
mode: 'seam',
implementations: ['deepseek-account-platform'],
consumers: ['api-account-controller', 'llm-deepseek'],
note: 'The Host owns browser authorization and local credentials; UI consumers receive state without tokens.',
},
{
key: 'authorization',
pkg: 'authorization',
title: 'Authorization flow registry',
mode: 'seam',
implementations: [],
consumers: ['llm-pi-ai'],
note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.',
},
{
key: 'productAnalytics',
pkg: 'client-product-analytics',
title: 'Desktop interaction collection',
mode: 'service',
note: 'Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy.',
},
{
key: 'otel',
pkg: 'otel',
title: 'Shared OTel reporting channels',
mode: 'service',
consumers: ['host-product-telemetry-otel', 'session-telemetry-otel'],
note: 'Product analytics and Session feedback adapters create independent reporting channels through one injected service.',
},
{
key: 'productTelemetry',
pkg: 'host-product-telemetry-otel',
title: 'Product usage event sender',
mode: 'service',
note: 'Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing.',
},
{
key: 'sessionTelemetry',
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',
title: 'Non-session storage hub',
mode: 'seam',
implementations: ['storage-json', 'storage-sqlite'],
consumers: ['storage-domain'],
note: 'Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives.',
},
{
key: 'storageDomain',
pkg: 'storage-domain',
title: 'Domain data facility',
mode: 'core',
consumers: ['workspace'],
note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.',
},
{
key: 'messageFeedback',
pkg: 'message-feedback',
title: 'Lifecycle-bound message feedback',
mode: 'core',
note: 'Owns per-assistant-message feedback in the canonical Session log, target validation, per-item compare-and-set, and the Host unary Remote contract. Feedback stays outside model history; log export follows the consumer policy.',
},
{
key: 'sessionFeedback',
pkg: 'command-feedback',
title: 'Session-level feedback recorder',
mode: 'core',
note: 'Records one Session-level remark with its category as a log-only feedback/record event on a live Session through the Host unary Remote contract; the /feedback command shares the same producer.',
},
{
key: 'workspaceRegistry',
pkg: 'workspace',
title: 'Workspace entity registry',
mode: 'core',
consumers: ['api-workspace-controller', 'api-session-controller'],
note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.',
},
{
key: 'sessionQuery',
pkg: 'session-query',
title: 'Session reads, traces, filters, and search',
mode: 'seam',
implementations: ['session-query-sqlite'],
consumers: ['session-reference', 'tool-session-query'],
note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering.',
},
{
key: 'fileReferences',
pkg: 'file-reference',
title: 'File reference discovery',
mode: 'seam',
implementations: ['file-reference-local'],
consumers: ['api-session-controller'],
note: 'The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents.',
},
{
key: 'sessionReferenceResolver',
pkg: 'session-reference',
title: 'Cross-session snapshot preparation',
mode: 'core',
note: 'Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax.',
},
{
key: 'sessionTitle',
pkg: 'session-title',
title: 'Log-backed session titles',
mode: 'seam',
implementations: ['session-title-first-prompt-llm', 'session-title-all-prompts-llm'],
note: 'Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration.',
},
{
key: 'systemPrompt',
pkg: 'system-prompt',
title: 'System prompt assembly registry',
mode: 'core',
consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-terminal', 'tool-web'],
note: 'Collects prompt sections and model-facing tool schemas for each step.',
},
{
key: 'tools',
pkg: 'tools',
title: 'Scoped tool registry and executor selection',
mode: 'core',
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-terminal', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web'],
note: 'Registers capabilities and scoped executors. Native and PTC providers own descriptions and complete raw call batches; their optional shared pipeline handles tool policy and dispatch.',
},
{
key: 'userQuestions',
pkg: 'user-questions',
title: 'Human question/answer seam',
mode: 'seam',
consumers: ['tool-ask-user'],
note: 'UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
{
key: 'planMode',
pkg: 'plan-mode',
title: 'Plan collaboration state',
mode: 'core',
note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.',
},
{
key: 'agentPresets',
pkg: 'agent-preset-registry',
title: 'Per-session agent composition',
mode: 'core',
note: 'Eagerly mounts YAML-declared preset revisions, binds Agents and cold readers to scoped contributions, and retains retired revisions until their last user releases them.',
},
{
key: 'commands',
pkg: 'commands',
title: 'Human command registry',
mode: 'core',
note: 'Plugins register direct human commands without sending invocations to the model.',
},
{
key: 'sessionProjections',
pkg: 'session-projection',
title: 'Session projection units',
mode: 'core',
consumers: ['api-session-controller', 'tool-todo', 'session-title'],
note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values.',
},
{
key: 'sessionProjectionCache',
pkg: 'session-projection-cache',
title: 'Persisted projection cache',
mode: 'core',
consumers: ['api-session-controller', 'session-query', 'session-reference'],
note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points), serves cached projection views, and accelerates prepared-Session projection hydration.',
},
{
key: 'skills',
pkg: 'skill',
title: 'Skill provider registry',
mode: 'seam',
implementations: ['sandbox-windows-acl', 'skill-badge', 'skill-filesystem', 'skill-office'],
consumers: ['tool-skill'],
note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.',
},
{
key: 'agents',
pkg: 'agent',
title: 'Agent service',
mode: 'core',
consumers: ['agent-loop', 'acp', 'subagent'],
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
key: 'agentDefaultModel',
pkg: 'agent-default-model',
title: 'Default Agent model selection',
mode: 'core',
consumers: ['api-session-controller', 'headless'],
note: 'Reads the default ModelSelection from volatile Config and saves selections through the profile editor.',
},
{
key: 'agentLoop',
pkg: 'agent-loop',
title: 'Concrete loop driver',
mode: 'bundle',
consumers: ['base', 'sdk-minimal'],
note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
},
{
key: 'schedule',
pkg: 'schedule',
title: 'Host scheduled messages',
mode: 'core',
note: 'Stores tasks independently of Session activation and queues due messages in the original Session.',
},
{
key: 'goals',
pkg: 'goal',
title: 'Same-session goal domain',
mode: 'core',
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
},
{
key: 'ssh',
pkg: 'ssh',
title: 'POSIX SSH connection owner',
mode: 'core',
consumers: ['fs-ssh', 'subprocess-ssh', 'sandbox-ssh'],
note: 'Owns one authenticated OpenSSH connection, installed helper identity, independent program streams and disconnect cleanup for the paired remote providers.',
},
{
key: 'subprocess',
pkg: 'subprocess',
title: 'Subprocess seam',
mode: 'seam',
implementations: ['subprocess-local', 'subprocess-ssh'],
consumers: ['bash-local', 'bash-sandbox', 'terminal-bash', 'lsp-stdio', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'],
note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation.',
},
{
key: 'shell',
pkg: 'shell',
title: 'Bash executor seam',
mode: 'seam',
implementations: ['bash-local', 'bash-sandbox', 'pwsh-local'],
consumers: ['tool-bash', 'tool-pwsh', 'hooks-claude-code', 'hooks-codex'],
note: 'The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them.',
},
{
key: 'shellEnv',
pkg: 'shell-env',
title: 'Managed bash environment registry',
mode: 'core',
consumers: ['tool-bash', 'tool-pwsh'],
note: 'Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace.',
},
{
key: 'terminals',
pkg: 'terminal',
title: 'Persistent PTY session registry',
mode: 'seam',
implementations: ['terminal-bash'],
consumers: ['tool-terminal'],
note: 'The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-terminal exposes the owner-scoped model tools.',
},
{
key: 'sandbox',
pkg: 'sandbox',
title: 'Process-sandbox seam',
mode: 'seam',
implementations: ['sandbox-local', 'sandbox-ssh'],
consumers: ['bash-sandbox', 'terminal-bash'],
note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
},
{
key: 'sandboxPolicy',
pkg: 'sandbox-policy',
title: 'Sandbox policy home',
mode: 'core',
implementations: [],
consumers: ['bash-sandbox', 'fs-sandbox', 'terminal-bash'],
note: 'The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots.',
},
{
key: 'approval',
pkg: 'user-approval',
title: 'Approval seam',
mode: 'seam',
implementations: [],
consumers: ['tools', 'tool-bash', 'acp'],
note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.',
},
{
key: 'permissionPresets',
pkg: 'permission-presets',
title: 'Permission presets',
mode: 'core',
implementations: [],
note: 'User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events.',
},
{
key: 'ptcRuntime',
pkg: 'ptc-runtime',
title: 'PTC execution seam',
mode: 'seam',
implementations: ['ptc-runtime-node', 'experimental-ptc-runtime-python'],
consumers: ['tool-executor-ptc', 'workflow-ptc'],
note: 'Runs programs against host-provided async bindings; tool-executor-ptc owns PTC presentation and workflow-ptc owns workflow orchestration.',
},
{
key: 'fs',
pkg: 'fs',
title: 'Filesystem provider seam',
mode: 'seam',
implementations: ['fs-local', 'fs-sandbox', 'fs-ssh'],
consumers: ['tool-fs'],
companions: ['fs-observation-policy'],
note: 'tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate.',
},
{
key: 'compaction',
pkg: 'compaction',
title: 'Compaction seam',
mode: 'seam',
implementations: ['compaction-basic'],
consumers: ['compaction-basic'],
note: 'The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool.',
},
{
key: 'subagents',
pkg: 'subagent',
title: 'Subagent provider and continuation service',
mode: 'seam',
implementations: ['subagent-spawn-in-process', 'subagent-fork-in-process', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'],
consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'],
note: 'Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results.',
},
{
key: 'speechToText',
pkg: 'experimental-speech-to-text',
title: 'Experimental speech recognition providers',
mode: 'seam',
implementations: ['experimental-speech-to-text-sensevoice'],
consumers: ['experimental-api-speech-to-text'],
note: 'Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission.',
},
{
key: 'agentTeams',
pkg: 'experimental-agent-team',
title: 'Agent Teams coordination domain',
mode: 'core',
consumers: ['experimental-tool-agent-team'],
note: 'Owns the implicit-root roster, direct peer inbox delivery, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls.',
},
{
key: 'claudeCodeMods',
pkg: 'experimental-claude-code-mods',
title: 'Claude Code mods bridge',
mode: 'core',
note: 'Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote.',
},
{
key: 'inspector',
pkg: 'inspector',
title: 'Cross-realm runtime inspection',
mode: 'core',
note: 'Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API.',
},
{
key: 'jobs',
pkg: 'jobs',
title: 'Background job registry',
mode: 'seam',
implementations: ['jobs-local'],
consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-jobs', 'api-job-controller'],
note: 'Producers (background bash/pwsh and PTY sends) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.',
},
{
key: 'web',
pkg: 'web',
title: 'Web access provider registry',
mode: 'seam',
implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-deepseek-api', 'web-fetch-http'],
consumers: ['tool-web'],
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
},
{
key: 'spillStore',
pkg: 'spill',
title: 'Spill storage seam',
mode: 'seam',
implementations: ['spill-local'],
consumers: ['spill-policy'],
note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
},
{
key: 'directoryPicker',
pkg: 'host-directory-picker',
title: 'Workspace-directory picking seam',
mode: 'seam',
implementations: ['host-directory-picker-native', 'host-directory-picker-browse'],
consumers: ['api-workspace-controller'],
note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement).',
},
{
key: 'webServer',
pkg: 'host-webserver',
title: 'HTTP route registration',
mode: 'core',
consumers: ['client-connection', 'client-modules', 'client-hmr'],
note: 'Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes.',
},
{
key: 'webApp',
pkg: 'web-app',
title: 'Served Web GUI',
mode: 'core',
consumers: ['harness-dev-context'],
note: 'The web-runtime row advertises one URL for its Web GUI: the configured publicUrl, otherwise the listener origin; DSH_WEB_URL, the startup URL, and harness-dev-context read the same value.',
},
{
key: 'clientModules',
pkg: 'client-modules',
title: 'Client plugin graph host',
mode: 'core',
consumers: ['client-hmr'],
note: 'Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.',
},
{
key: 'workflowEngine',
pkg: 'workflow',
title: 'Workflow script engine',
mode: 'seam',
implementations: ['workflow-ptc'],
consumers: ['tool-workflow', 'tool-ralph'],
note: 'One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
},
{
key: 'webhookRuntime',
pkg: 'webhook',
title: 'Webhook rule runtime',
mode: 'core',
consumers: ['webhook-github'],
note: 'Provider adapters dispatch authenticated deliveries; trusted plugins register independent process-local rules, and the runtime turns non-null results into ordinary Workspace-backed Sessions without delivery or completion state.',
},
{
key: 'lsp',
pkg: 'lsp',
title: 'Language-server navigation seam',
mode: 'seam',
implementations: ['lsp-stdio'],
consumers: ['tool-lsp'],
note: 'Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result.',
},
{
key: 'dynamicCordisRunner',
pkg: 'cordis-host-runner',
title: 'Dynamic Cordis package host runner',
mode: 'core',
consumers: ['tool-cordis'],
note: 'Owns the in-memory definition registry, the vm sandbox for host halves, and the request-run round trip; browser pages reach the same service over the wire through its remote namespace.',
},
{
key: 'cordisInspect',
pkg: 'cordis-host-runner',
title: 'Dynamic Cordis inspect registry',
mode: 'core',
consumers: ['tool-cordis'],
note: 'Registers host inspect providers, mirrors the client provider manifest, and routes client queries through the dynamic Cordis transport.',
},
{
key: 'liveshareShares',
pkg: 'experimental-liveshare',
title: 'Owner share management Remote',
mode: 'service',
note: 'Publishes the owner share-management operations as one Typert Remote namespace that the owner client face calls; every method delegates to the same operations the same-origin routes used.',
},
]
function generatedHeader(title: string): string[] {
return [
'<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
' Run `pnpm run gen-doc-graphs` to regenerate. -->',
'',
`# ${title}`,
'',
]
}
function maintenanceFooter(source: string): string[] {
return [`Maintenance mode: ${source}.`, '']
}
function graphIndexLink(rel: string): string {
return relative('docs', rel).replaceAll('\\', '/')
}
function linkFromDoc(docRel: string, targetRel: string): string {
return relative(dirname(docRel), targetRel).replaceAll('\\', '/')
}
function mermaidCode(value: string): string {
return `<code>${value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code>`
}
function repoLink(path: string, label: string, up = '..'): string {
return `[${label}](${up}/${path})`
}
function sourceLink(source: string, up = '..'): string {
return repoLink(source.split(':')[0] ?? source, `\`${source}\``, up)
}
function pkgLink(pkg: Pkg | undefined, fallback: string, up = '..'): string {
return pkg ? repoLink(pkg.rel, `\`${pkg.short}\``, up) : `\`${fallback}\``
}
function pkgList(names: string[] | undefined, pkgsByShort: Map<string, Pkg>): string {
if (!names || names.length === 0) return '-'
return names.map(name => pkgLink(pkgsByShort.get(name), name)).join(', ')
}
function tableCell(value: string): string {
return value.replace(/\|/g, '\\|').replace(/\n/g, '<br>')
}
function assertServiceRolesComplete(services: readonly ServiceEntry[]): void {
const discovered = new Set(services.map(service => service.key))
const classified = new Set(SERVICE_ROLES.map(role => role.key))
const missing = [...discovered].filter(key => !classified.has(key)).sort()
const stale = [...classified].filter(key => !discovered.has(key)).sort()
if (missing.length || stale.length) {
throw new Error([
missing.length ? `missing service role classification: ${missing.join(', ')}` : '',
stale.length ? `stale service role classification: ${stale.join(', ')}` : '',
].filter(Boolean).join('; '))
}
}
function renderCapabilitySeams(pkgs: Pkg[], services: readonly ServiceEntry[]): string {
assertServiceRolesComplete(services)
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const maintenance = 'hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard'
const nodes = new Map<string, string>()
const edges = new Set<string>()
const companionEdges = new Set<string>()
const addNode = (id: string, label: string): void => {
if (!nodes.has(id)) nodes.set(id, ` ${id}["${escLabel(label)}"]`)
}
const addEdge = (from: string, to: string): void => { edges.add(` ${from} --> ${to}`) }
const lines = generatedHeader('Capability Seams And Core Services')
lines.push(
'A service can be a core spine service, a swappable capability seam, a bundle/composition point, or a standalone service. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.',
'',
'```mermaid',
'flowchart LR',
)
for (const role of SERVICE_ROLES) {
const svc = nodeId('svc', role.key)
const owner = nodeId('pkg', role.pkg)
addNode(owner, role.pkg)
addNode(svc, `ctx.${role.key}<br/>${role.title}`)
addEdge(owner, svc)
for (const impl of role.implementations ?? []) {
addNode(nodeId('pkg', impl), impl)
addEdge(nodeId('pkg', impl), svc)
}
for (const consumer of role.consumers ?? []) {
addNode(nodeId('pkg', consumer), consumer)
addEdge(svc, nodeId('pkg', consumer))
}
for (const companion of role.companions ?? []) {
addNode(nodeId('pkg', companion), companion)
companionEdges.add(` ${svc} -. event gate .-> ${nodeId('pkg', companion)}`)
}
}
lines.push(...nodes.values(), ...[...edges].sort(), ...[...companionEdges].sort())
lines.push('```', '', '| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |', '| --- | --- | --- | --- | --- | --- | --- |')
for (const role of SERVICE_ROLES) {
lines.push(`| \`ctx.${role.key}\` | \`${role.mode}\` | ${pkgLink(pkgsByShort.get(role.pkg), role.pkg)} | ${pkgList(role.implementations, pkgsByShort)} | ${pkgList(role.consumers, pkgsByShort)} | ${pkgList(role.companions, pkgsByShort)} | ${tableCell(role.note)} |`)
}
lines.push('', ...maintenanceFooter(maintenance))
return lines.join('\n')
}
function parseExampleCordis(rel: string): ExamplePlugin[] {
const text = readFileSync(resolve(root, rel), 'utf8')
const plugins: ExamplePlugin[] = []
let current: { id: string; name?: string } | null = null
const flush = (): void => {
if (current?.name) plugins.push({ id: current.id, name: current.name })
}
for (const line of text.split('\n')) {
// Top-level rows (`- id:`) and bundle-patch insert rows (` - id:`).
const id = /^\s*-\s+id:\s+(.+?)\s*$/.exec(line)
if (id?.[1] !== undefined) {
flush()
current = { id: stripYamlScalar(id[1]) }
continue
}
const name = /^\s+name:\s+(.+?)\s*$/.exec(line)
if (name?.[1] !== undefined && current) current.name = stripYamlScalar(name[1])
}
flush()
return plugins
}
function stripYamlScalar(value: string): string {
return value.trim().replace(/^['"]|['"]$/g, '')
}
const APP_EXAMPLES = [
{
id: 'dsh_base',
rel: 'apps/cli/composition.md',
title: 'DSH Base Composition',
label: 'packages/bundle/base/cordis.patch.yml',
config: 'packages/bundle/base/cordis.patch.yml',
summary: 'The dsh-base bundle patch shared by the web, headless, sdk, and acp profiles; their mode bundles and user layers patch over it, while sdk-minimal owns a separate standalone tree.',
},
]
type AppExample = typeof APP_EXAMPLES[number]
function renderAppComposition(example: AppExample): string {
const plugins = parseExampleCordis(example.config)
const maintenance = 'hybrid: the patch row list is parsed from its `cordis.yml`; app package expansion is curated from package source'
const lines = generatedHeader(example.title)
lines.push(
example.summary,
'',
'```mermaid',
'flowchart LR',
` cfg["${escLabel(example.label)}<br/>cordis.yml"]`,
)
for (const plugin of plugins) {
const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
lines.push(` cfg --> ${pluginNode}`)
}
lines.push(
'```',
'',
'| Plugin id | Package / module |',
'| --- | --- |',
...plugins.map(plugin => `| \`${plugin.id}\` | \`${plugin.name}\` |`),
'',
`Source config: [\`${example.config}\`](${linkFromDoc(example.rel, example.config)}).`,
)
lines.push('', ...maintenanceFooter(maintenance))
return lines.join('\n')
}
type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>
/**
* The only method names visitSource classifies; receiver typing runs on these
* alone. Obligation: every method name matched by a branch inside visitSource
* must appear here — the prefilter drops non-members before any branch runs,
* so a branch for an unlisted name is silently dead.
*/
const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch'])
/**
* Collect event dispatch/listener relations from real cross-file receiver types.
*
* TODO: the program is seeded from the host aggregate alone (ts-project.ts
* documents why: one program cannot hold both faces' Context merges), so a
* Client package enters only when a host file imports it. Client-face
* listeners on client-face events are therefore under-reported —
* `connection/reset` omits `ui-skill`/`ui-agent-preset`. Closing it needs a
* second Client program whose relations merge into these, not a wider seed.
*/
export class EventRelationCollector {
private readonly relations = new Map<string, EventRelation>()
private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>()
private readonly localCalleeProofs = new Map<ts.FunctionDeclaration, boolean>()
private globalCallSites: CallSiteIndex | null = null
private readonly contextType: ts.Type
private readonly agentDispatchType: ts.Type
private readonly eventsServiceType: ts.Type
private readonly packageSourceFiles: ReadonlySet<ts.SourceFile>
constructor(
private readonly project: TypeScriptProject,
private readonly sources: readonly PackageSource[],
) {
this.contextType = this.declaredType('vendor/cordis/src/context.ts', 'Context')
this.agentDispatchType = this.declaredType('packages/core/agent/src/dispatch.ts', 'AgentEventDispatch')
this.eventsServiceType = this.declaredType('vendor/cordis/src/events.ts', 'EventsService')
this.packageSourceFiles = new Set(sources.map(source => source.sourceFile))
}
/** Return all event relations discovered from the Program. */
collect(): Map<string, EventRelation> {
for (const source of this.sources) this.visitSource(source)
return this.relations
}
/** Resolve one named class/interface declaration to its merged instance type. */
private declaredType(relativePath: string, name: string): ts.Type {
const sourceFile = this.project.sourceFile(relativePath)
const declaration = sourceFile.statements.find((statement): statement is ts.ClassDeclaration | ts.InterfaceDeclaration => {
return (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) && statement.name?.text === name
})
const symbol = declaration?.name && this.project.checker.getSymbolAtLocation(declaration.name)
if (!symbol) throw new Error(`cannot resolve TypeScript type ${name} from ${relativePath}`)
return this.project.checker.getDeclaredTypeOfSymbol(symbol)
}
/** Index resolved function calls in the given files for narrow argument-flow recovery. */
private buildCallSiteIndex(files: Iterable<ts.SourceFile>): CallSiteIndex {
const index: CallSiteIndex = new Map()
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const declaration = this.project.checker.getResolvedSignature(node)?.declaration
if (declaration) {
const calls = index.get(declaration) ?? []
calls.push(node)
index.set(declaration, calls)
}
}
ts.forEachChild(node, visit)
}
for (const file of files) visit(file)
return index
}
/**
* Return every indexed call resolving to one local helper declaration.
* Fast path: when every same-file reference to the non-exported helper is
* provably a direct callee, module scoping confines all of its calls to that
* file, so only that file is indexed. Any other reference form may alias
* the function value outward, so the original full package-source index
* decides instead.
*/
private callSitesFor(owner: ts.FunctionDeclaration): ts.CallExpression[] {
if (!this.globalCallSites && !this.provenLocalCallee(owner)) {
this.globalCallSites = this.buildCallSiteIndex(this.packageSourceFiles)
}
if (this.globalCallSites) return this.globalCallSites.get(owner) ?? []
const file = owner.getSourceFile()
let index = this.fileCallSites.get(file)
if (!index) {
index = this.buildCallSiteIndex([file])
this.fileCallSites.set(file, index)
}
return index.get(owner) ?? []
}
/**
* Prove every same-file reference to one helper is a direct callee. The
* proof owns its premises: an exported helper or a helper in a global
* script file (no import/export means program-wide scope, callable from
* another file with no same-file reference at all) fails immediately.
* Alias escapes (re-export statements, default exports, value reads)
* resolve back to the owner symbol at a non-callee position and fail the
* proof, as does anything the scan cannot positively classify.
*/
private provenLocalCallee(owner: ts.FunctionDeclaration): boolean {
const cached = this.localCalleeProofs.get(owner)
if (cached !== undefined) return cached
if (hasExportModifier(owner) || !ts.isExternalModule(owner.getSourceFile())) {
this.localCalleeProofs.set(owner, false)
return false
}
const name = owner.name
const ownerSymbol = name && this.project.checker.getSymbolAtLocation(name)
let proven = !!ownerSymbol
const refersToOwner = (identifier: ts.Identifier): boolean => {
// Shorthand properties resolve to the property symbol; ask for the value side.
const local = ts.isShorthandPropertyAssignment(identifier.parent)
? this.project.checker.getShorthandAssignmentValueSymbol(identifier.parent)
: this.project.checker.getSymbolAtLocation(identifier)
if (!local) return false
const symbol = local.flags & ts.SymbolFlags.Alias
? this.project.checker.getAliasedSymbol(local)
: local
return symbol === ownerSymbol
}
const visit = (node: ts.Node): void => {
if (!proven) return
if (ts.isIdentifier(node) && node !== name && node.text === name?.text
&& !isDirectCallee(node) && refersToOwner(node)) {
proven = false
return
}
ts.forEachChild(node, visit)
}
visit(owner.getSourceFile())
this.localCalleeProofs.set(owner, proven)
return proven
}
/** Walk one package source file and classify event API calls by receiver type. */
private visitSource(source: PackageSource): void {
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
if (this.isAgentEventEmitter(node.expression)) {
const event = node.arguments[2]
if (event) {
for (const name of this.finiteStringValues(event) ?? []) {
this.addDispatcher(name, source.pkg, 'emitAgentEvent')
}
}
} else if (ts.isPropertyAccessExpression(node.expression) && EVENT_API_METHODS.has(node.expression.name.text)) {
const receiverKind = this.receiverKind(node.expression.expression)
const method = node.expression.name.text
if (receiverKind === 'events-service' && method === 'dispatch') {
const argumentList = node.arguments[1]
if (argumentList) {
for (const event of this.eventNamesFromArgumentList(argumentList, new Set())) {
this.addDispatcher(event, source.pkg, 'events.dispatch')
}
}
} else if (receiverKind === 'context' || receiverKind === 'agent-dispatch') {
const eventNames = this.eventNamesFromCall(node, receiverKind)
if (method === 'on' || method === 'once') {
for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
}
}
}
}
ts.forEachChild(node, visit)
}
visit(source.sourceFile)
}
/** Match the exported contained-notification helper by declaration identity. */
private isAgentEventEmitter(expression: ts.Expression): boolean {
if (!ts.isIdentifier(expression)) return false
const local = this.project.checker.getSymbolAtLocation(expression)
if (!local) return false
const symbol = local.flags & ts.SymbolFlags.Alias
? this.project.checker.getAliasedSymbol(local)
: local
const declarations = symbol.declarations ?? []
return declarations.some((declaration) => {
return ts.isFunctionDeclaration(declaration)
&& declaration.name?.text === 'emitAgentEvent'
&& this.project.relativePath(declaration.getSourceFile()) === 'packages/core/agent/src/dispatch.ts'
})
}
/** Classify a receiver using assignability to the repository's actual event API types. */
private receiverKind(receiver: ts.Expression): EventReceiverKind | undefined {
const type = this.project.checker.getTypeAtLocation(receiver)
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return undefined
if (this.project.checker.isTypeAssignableTo(type, this.eventsServiceType)) return 'events-service'
if (this.project.checker.isTypeAssignableTo(type, this.contextType)) return 'context'
if (this.project.checker.isTypeAssignableTo(type, this.agentDispatchType)) return 'agent-dispatch'
return undefined
}
/** Resolve the event-name argument for Context and fused agent dispatch calls. */
private eventNamesFromCall(call: ts.CallExpression, receiverKind: Exclude<EventReceiverKind, 'events-service'>): Set<string> {
const candidates = receiverKind === 'context' ? call.arguments.slice(0, 2) : call.arguments.slice(0, 1)
for (const candidate of candidates) {
const values = this.finiteStringValues(candidate)
if (values) return values
}
return new Set()
}
/** Recover the event slot from the argument array handed to EventsService.dispatch(). */
private eventNamesFromArgumentList(expression: ts.Expression, seen: Set<ts.Node>): Set<string> {
const current = unwrapExpression(expression)
if (seen.has(current)) return new Set()
seen.add(current)
if (ts.isArrayLiteralExpression(current)) {
for (const element of current.elements.slice(0, 2)) {
if (ts.isOmittedExpression(element) || ts.isSpreadElement(element)) continue
const values = this.finiteStringValues(element)
if (values) return values
}
return new Set()
}
if (ts.isConditionalExpression(current)) {
return unionSets(
this.eventNamesFromArgumentList(current.whenTrue, new Set(seen)),
this.eventNamesFromArgumentList(current.whenFalse, new Set(seen)),
)
}
if (!ts.isIdentifier(current)) return new Set()
const symbol = this.project.checker.getSymbolAtLocation(current)
if (!symbol) return new Set()
const events = new Set<string>()
for (const declaration of symbol.declarations ?? []) {
if (ts.isVariableDeclaration(declaration) && declaration.initializer && isConstDeclaration(declaration)) {
addAll(events, this.eventNamesFromArgumentList(declaration.initializer, new Set(seen)))
} else if (ts.isParameter(declaration)) {
addAll(events, this.eventNamesFromParameter(declaration, seen))
}
}
return events
}
/** Follow a non-exported local helper parameter back to every resolved call site. */
private eventNamesFromParameter(parameter: ts.ParameterDeclaration, seen: Set<ts.Node>): Set<string> {
const owner = parameter.parent
if (!ts.isFunctionDeclaration(owner) || hasExportModifier(owner)) return new Set()
const index = owner.parameters.indexOf(parameter)
if (index < 0) return new Set()
const events = new Set<string>()
for (const call of this.callSitesFor(owner)) {
const argument = call.arguments[index]
if (argument) addAll(events, this.eventNamesFromArgumentList(argument, new Set(seen)))
}
return events
}
/** Return a finite string-literal value set, rejecting widened and generic strings. */
private finiteStringValues(expression: ts.Expression): Set<string> | undefined {
const current = unwrapExpression(expression)
if (ts.isStringLiteralLike(current)) return new Set([current.text])
if (this.isForwardedAgentEventParameter(current)) return undefined
return finiteStringTypeValues(this.project.checker.getTypeAtLocation(current))
}
/** Reject the contextual parameter inside the AgentEventDispatch forwarding object. */
private isForwardedAgentEventParameter(expression: ts.Expression): boolean {
if (!ts.isIdentifier(expression)) return false
const declarations = this.project.checker.getSymbolAtLocation(expression)?.declarations ?? []
return declarations.some((declaration) => {
if (!ts.isParameter(declaration)) return false
const method = declaration.parent
if (!ts.isMethodDeclaration(method) || !ts.isObjectLiteralExpression(method.parent)) return false
const contextualType = this.project.checker.getContextualType(method.parent)
return contextualType !== undefined
&& this.project.checker.isTypeAssignableTo(contextualType, this.agentDispatchType)
})
}
/** Get or create one relation row. */
private ensure(event: string): EventRelation {
const existing = this.relations.get(event)
if (existing) return existing
const relation = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
this.relations.set(event, relation)
return relation
}
/** Add one dispatcher method without duplicating package/method labels. */
private addDispatcher(event: string, pkg: string, method: string): void {
const relation = this.ensure(event)
const methods = relation.dispatchers.get(pkg) ?? new Set<string>()
methods.add(method)
relation.dispatchers.set(pkg, methods)
}
}
/** Return whether an identifier is the callee of a call, seen through value-preserving wrappers. */
function isDirectCallee(identifier: ts.Identifier): boolean {
let current: ts.Node = identifier
while (
ts.isParenthesizedExpression(current.parent)
|| ts.isAsExpression(current.parent)
|| ts.isTypeAssertionExpression(current.parent)
|| ts.isNonNullExpression(current.parent)
|| ts.isSatisfiesExpression(current.parent)
) {
current = current.parent
}
return ts.isCallExpression(current.parent) && current.parent.expression === current
}
/** Peel syntax-only wrappers that do not change an expression's runtime value. */
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression
while (
ts.isParenthesizedExpression(current)
|| ts.isAsExpression(current)
|| ts.isTypeAssertionExpression(current)
|| ts.isNonNullExpression(current)
|| ts.isSatisfiesExpression(current)
) {
current = current.expression
}
return current
}
/** Return every value only when a type is a closed string-literal union. */
function finiteStringTypeValues(type: ts.Type): Set<string> | undefined {
if (type.flags & ts.TypeFlags.StringLiteral) {
return new Set([(type as ts.StringLiteralType).value])
}
if (type.flags & ts.TypeFlags.Never) return new Set()
if (!type.isUnion()) return undefined
const values = new Set<string>()
for (const member of type.types) {
const memberValues = finiteStringTypeValues(member)
if (!memberValues) return undefined
addAll(values, memberValues)
}
return values
}
/** Return whether a variable declaration belongs to a const declaration list. */
function isConstDeclaration(declaration: ts.VariableDeclaration): boolean {
return (declaration.parent.flags & ts.NodeFlags.Const) !== 0
}
/** Return whether a declaration is visible to callers outside its source module. */
function hasExportModifier(node: ts.Node): boolean {
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => {
return modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword
}) ?? false)
}
/** Add every member of source to target. */
function addAll<T>(target: Set<T>, source: ReadonlySet<T>): void {
for (const value of source) target.add(value)
}
/** Return the union of two sets without mutating either input. */
function unionSets<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): Set<T> {
const out = new Set(left)
addAll(out, right)
return out
}
/**
* Select the package source files of one project in deterministic order.
* @param project - the loaded repository TypeScript project.
* @returns `packages/<group>/<pkg>/src` files tagged with their package name.
*/
export function collectPackageSources(project: TypeScriptProject): PackageSource[] {
return project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
const rel = project.relativePath(sourceFile)
const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
}).sort((left, right) => left.rel.localeCompare(right.rel))
}
function collectEventRelations(): Map<string, EventRelation> {
const project = new TypeScriptProject(root)
return new EventRelationCollector(project, collectPackageSources(project)).collect()
}
function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
if (map.size === 0) return '-'
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([pkg, methods]) => `${pkgLink(pkgsByShort.get(pkg), pkg)} (${[...methods].sort().map(m => `\`${m}\``).join(', ')})`)
.join(', ')
}
function listenerPackages(listeners: Set<string>, pkgsByShort: Map<string, Pkg>): string {
if (listeners.size === 0) return '-'
return [...listeners].sort().map(pkg => pkgLink(pkgsByShort.get(pkg), pkg)).join(', ')
}
function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): string {
const relations = collectEventRelations()
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program'
const lines = generatedHeader('Event Producer And Consumer Matrix')
lines.push(
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. Events are many-to-many, so the dense relation data is presented as a table rather than one large graph. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
'',
'Application listeners are outside this package matrix. Desktop Host subscribes to `plugin-manager/operation` through [`installOperationDiagnostics`](../apps/desktop-host/src/operation-diagnostics.ts) and forwards bounded records to the shell.',
'',
)
const rows = ['| Event | Mode | Declared in | Dispatchers | Listeners |', '| --- | --- | --- | --- | --- |']
for (const event of [...events].sort((a, b) => a.name.localeCompare(b.name))) {
const relation = relations.get(event.name) ?? { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
rows.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
}
lines.push(renderGeneratedRegion('event-producer-consumer:events', rows.join('\n')))
// Every declared event needs a dispatcher: zero means dead vocabulary or an
// unrecognized semantic dispatch form. Listener-free extension points remain
// valid. Client-declared events are exempt: the relation scan seeds the HOST
// aggregate program only (host+client cannot share one program — the cordis
// Context merges collide), so client dispatch sites are structurally
// invisible here; their rows stay in the table for the declarations' sake.
const undispatched = [...events]
.filter(event => !event.source.startsWith('packages/client/'))
.filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
.map(event => event.name)
.sort()
if (undispatched.length > 0) {
throw new Error(
`event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} `
+ `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch form the semantic scan misses `
+ '(teach scripts/gen-doc-graphs.ts that form)',
)
}
const declared = new Set(events.map(event => event.name))
const extra = [...relations.keys()].filter(event => !declared.has(event)).sort()
if (extra.length > 0) {
const extraRows = ['| Event string | Dispatchers | Listeners |', '| --- | --- | --- |']
for (const event of extra) {
const relation = relations.get(event)
if (!relation) continue
extraRows.push(`| \`${event}\` | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
}
lines.push(
'',
'## Non-harness or undeclared event strings seen in package source',
'',
renderGeneratedRegion('event-producer-consumer:undeclared', extraRows.join('\n')),
)
}
lines.push('', ...maintenanceFooter(maintenance))
return lines.join('\n')
}
function renderLifecycle(): string {
const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'
return [
...generatedHeader('Agent Turn And Step Lifecycle'),
'This sequence is the visual companion to [architecture.md](architecture.md#turn-flow). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
'',
'```mermaid',
'sequenceDiagram',
' participant User',
' participant Agent',
' participant Driver',
' participant Hooks as hook listeners',
' participant Prompt as ctx.systemPrompt',
' participant LLM as ctx.llm',
' participant Tools as ctx.tools',
' participant Session',
' participant SDK as UI or SDK listener',
' User->>Agent: followup(content)',
` Agent-->>SDK: ${mermaidCode('agent/inbox/spliced')}`,
` Agent-->>SDK: ${mermaidCode('agent/inbox/inserted')} { message }`,
' Agent->>Driver: queued work wakes driver',
` Driver-->>SDK: ${mermaidCode('agent/status')} running`,
` Driver->>Session: ${mermaidCode('turn/start')}`,
' Note over Agent,Driver: claim pending next-step input plus one queued prompt',
` Driver-->>SDK: ${mermaidCode('agent/inbox/spliced')} pure deletion`,
` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
' Driver->>Tools: resolve({ agent, modelSelection, signal }) once per proposed step',
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall with the step tool set`,
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
' Hooks-->>Driver: authoritative reject or enter(messages)',
' alt proposed step rejected, first batch empty, or pre-step failed',
' Driver-->>Driver: claimed batch stays removed, the open turn spends no step',
' else enter proposed step',
` Driver->>Session: ${mermaidCode('step/start')}`,
` Driver->>Hooks: ${mermaidCode('agent/request')} waterfall`,
' Driver->>LLM: prepareCall(config, signal)',
' Note over Driver,LLM: cancellation during either async phase commits neither system nor users',
' Note over Driver,Session: synchronous admission using the prepared call capability',
` Driver->>Session: ${mermaidCode('system/message')} ordered per-node reconciliation`,
` Driver->>Session: ${mermaidCode('user/message')} per entered message`,
` Driver->>Session: ${mermaidCode('request/header')} and ${mermaidCode('request/context')} as needed`,
' Driver->>Driver: derive and freeze request from the log',
` Driver->>LLM: bound prepared call through ${mermaidCode('llm/stream')} waterfall`,
' LLM-->>Driver: StreamChunk*',
` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} chunk*`,
' alt final adapter or terminal in-band request failure',
` Driver->>Session: ${mermaidCode('assistant/attempt')}`,
` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} committed end`,
` Driver->>Hooks: ${mermaidCode('agent/request-error')} waterfall`,
' Hooks-->>Driver: return retry action or preserve the original error',
' Note over Driver,LLM: retry in the open step: prepare and reconcile the same rendered assembly without repeating pre-step or users',
' else model request succeeded',
` Driver->>Session: ${mermaidCode('assistant/message')}`,
` Driver-->>SDK: ${mermaidCode('agent/assistant-stream')} committed end`,
' Driver->>Tools: classify pending call by executionMode',
' loop barriers and bounded rolling pool, reclassify before start',
' opt call starts',
` Driver->>Session: ${mermaidCode('tool/call')}`,
' Driver->>Tools: ordered pre, concurrent execute',
' Tools-->>Session: tool-owned events when applicable',
' end',
' opt next model-order result ready',
' Driver->>Tools: ordered post',
` Driver->>Session: ${mermaidCode('tool/result')}`,
' end',
' end',
` Driver->>Session: ${mermaidCode('step/end')}`,
' Driver->>Tools: dispose the step tool set',
' opt natural stop and next-step inbox empty',
` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`,
' end',
' opt next-step input is pending',
' Driver-->>Driver: claim pending next-step input',
` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
' Hooks-->>Driver: authoritative reject or enter(messages)',
' end',
' end',
' end',
` Driver->>Session: ${mermaidCode('turn/end')}`,
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
'```',
'',
'The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes, and embeds the exact compact timed stream. Empty content stays out of derived history. A failed, retried, cancelled, or stream-error attempt that reaches settlement without a surface message records its stream as `assistant/attempt`. Live `agent/assistant-stream` chunk frames are transient; replay reads either durable settlement, and a hard process loss before settlement leaves no durable attempt stream.',
'',
'`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery runs within the open step and retries only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. Each retry prepares its call and reconciles the retained rendered assembly before request derivation, without repeating assembly, pre-step, or user admission.',
'',
'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages and `startsRequestSeries` unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.',
'',
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination API for queue/status, prompt interception, request construction, steering, continuation, and errors.',
'',
...maintenanceFooter(maintenance),
].join('\n')
}
function renderToolPipeline(): string {
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
return [
...generatedHeader('Tool Execution Pipeline'),
'Built-in executors own raw batch parsing, scheduling, cancellation, and Session call/result logging. This graph shows where their shared invocation pipeline applies policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward.',
'',
'```mermaid',
'flowchart TD',
' model["Assistant message contains tool-call block"]',
` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
' presentCall["UI pending card<br/>presentCall(args)"]',
` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
' guards["Registered monotonic guards<br/>deny or abstain; identity protected"]',
' denied["denied or approval refused<br/>tool body skipped"]',
` approval["${mermaidCode('ctx.approval')} one-shot prompt<br/>absent or unanswerable: deny"]`,
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
' toolBody["Registered tool execute() body"]',
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/ptc-dispatch')}"]`,
' project["ToolDefinition.projectContent<br/>execution-prepared text and images"]',
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
' normalized["Invocation pipeline normalization<br/>pipeline/result snapshot throws become isError"]',
' finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]',
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
' context["Active-batch additionalContexts FIFO<br/>injected user/message after recorded tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
' allResults["Tool batch settled<br/>recorded tool/result events complete"]',
' presentResult["UI completed card<br/>presentResult(args, result)"]',
' executor["Selected ToolExecutor.execute<br/>raw batch, Agent, optional logging step"]',
' model --> executor --> toolCall',
' toolCall --> presentCall',
' toolCall --> pre',
' pre -->|allow| guards',
' guards -->|allow| around',
' guards -->|deny| denied',
' guards -.->|throw| normalized',
' around --> toolBody',
' pre -->|deny| denied',
' pre -->|ask| approval',
' approval -->|allowed-once| guards',
' approval -->|rejected, cancelled, unavailable| denied',
' approval -.->|throw| normalized',
' denied --> project',
' pre -.->|throw| normalized',
' toolBody --> fsGate',
' fsGate --> toolBody',
' toolBody --> owned',
' toolBody --> around',
' around --> project',
' project --> post',
' project -.->|throw| normalized',
' around -.->|wrapper throws| normalized',
' post -.->|throw| normalized',
' post --> finalize',
' normalized --> finalize',
' finalize --> final',
' final --> toolResult',
' toolResult --> presentResult',
' toolResult --> allResults',
' allResults --> context',
'```',
'',
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The pipeline losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its ordered sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
'',
...maintenanceFooter(maintenance),
].join('\n')
}
function renderDocs(): GraphDoc[] {
const pkgs = collectPackageGraph(root, GROUP_ORDER, 'gen-doc-graphs')
const { model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
const docs: GraphDoc[] = [
{ rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs, model.services) },
...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
{ rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs, model.events) },
{ rel: 'docs/agent-lifecycle.md', content: renderLifecycle() },
{ rel: 'docs/tool-execution-pipeline.md', content: renderToolPipeline() },
]
docs.unshift({ rel: 'docs/graph-atlas.md', content: renderIndex(docs) })
const events = docs.find(doc => doc.rel === 'docs/event-producer-consumer.md')
if (events !== undefined) docs.push(spliceChineseRegions(events))
return docs
}
/**
* Splice a generated page's regions into its authored Chinese counterpart,
* localizing paired-document links; the surrounding Chinese prose stays authored.
*/
function spliceChineseRegions(doc: GraphDoc): GraphDoc {
const rel = doc.rel.replace(/\.md$/, '.zh.md')
const context = {
repoRoot: root,
sourcePath: rel,
isTranslationPairSource: translationPairSourcePredicate(parseTranslationPairingManifest(
readFileSync(resolve(root, 'scripts/translation-pairing.manifest.json'), 'utf8'),
)),
}
let content = readFileSync(resolve(root, rel), 'utf8')
for (const region of generatedRegions(doc.content)) {
content = spliceGeneratedRegion(content, rewriteTranslationLinkLocales(region.text, context).content)
}
return { rel, content }
}
function renderIndex(docs: GraphDoc[]): string {
const labels: Record<string, string> = {
'docs/capability-seams.md': 'capability seams and core services',
'apps/cli/composition.md': 'dsh shared base composition',
'docs/event-producer-consumer.md': 'event producer/consumer matrix',
'docs/agent-lifecycle.md': 'agent turn and step lifecycle',
'docs/tool-execution-pipeline.md': 'tool execution pipeline',
}
const modes: Record<string, string> = {
'docs/capability-seams.md': 'hybrid generated',
'apps/cli/composition.md': 'hybrid generated',
'docs/event-producer-consumer.md': 'hybrid generated',
'docs/agent-lifecycle.md': 'curated',
'docs/tool-execution-pipeline.md': 'curated',
}
const rows = [
'| [module dependency graph](module-graph.md) | `generated` |',
'| [tool schema catalog and package map](tool-catalog.md) | `generated` |',
...docs.map((doc) => {
const link = graphIndexLink(doc.rel)
return `| [${labels[doc.rel] ?? link}](${link}) | \`${modes[doc.rel] ?? 'generated'}\` |`
}),
]
const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode'
return [
...generatedHeader('Documentation Graph Index'),
'These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated Cordis API regions) and [tool-catalog.md](tool-catalog.md).',
'',
'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md).',
'',
'| Graph | Mode |',
'| --- | --- |',
...rows,
'',
'Regenerate with `pnpm run gen-doc-graphs`; verify freshness with `pnpm run verify-doc-graphs`.',
'',
...maintenanceFooter(maintenance),
].join('\n')
}
function memoize<A extends readonly unknown[], R>(
compute: (...args: A) => R,
key: (...args: A) => string,
copy: (value: R) => R,
): (...args: A) => R {
const results = new Map<string, R>()
return (...args) => {
const id = key(...args)
const recorded = results.get(id)
if (recorded !== undefined) return copy(recorded)
const value = compute(...args)
results.set(id, value)
return copy(value)
}
}
/**
* Run read-only analysis with memoized TypeScript filesystem probes.
*
* While `run` executes, `fileExists`, `directoryExists`, `realpath`, `readDirectory`,
* and `getDirectories` on `system` return the first result recorded for each distinct
* argument list, so every compiler host and config parse built on it, including the
* Typert programs and the event-relation Program, shares one view of the filesystem.
* The workspace must not change while `run` executes. A probe that throws records
* nothing, and array results are copied for each caller. The original methods are
* restored when `run` returns or throws. The memo patches `system` itself, so `run`
* must be synchronous and must not call this function again.
* @param run - read-only analysis that runs under the memo.
* @param system - TypeScript system whose probes are memoized.
* @returns the value returned by `run`.
*/
export function withMemoizedFileSystemProbes<T>(run: () => T, system: ts.System = ts.sys): T {
const probes = ['fileExists', 'directoryExists', 'realpath', 'readDirectory', 'getDirectories'] as const
const descriptors = probes.map(name => [name, Object.getOwnPropertyDescriptor(system, name)] as const)
const original: ts.System = { ...system }
const same = <V>(value: V): V => value
const copied = (value: string[]): string[] => [...value]
system.fileExists = memoize((path: string) => original.fileExists(path), path => path, same)
system.directoryExists = memoize((path: string) => original.directoryExists(path), path => path, same)
if (original.realpath !== undefined) system.realpath = memoize(original.realpath.bind(original), path => path, same)
system.readDirectory = memoize(
(path: string, extensions?: readonly string[], exclude?: readonly string[], include?: readonly string[], depth?: number) =>
original.readDirectory(path, extensions, exclude, include, depth),
(path, extensions, exclude, include, depth) => JSON.stringify([path, extensions, exclude, include, depth]),
copied,
)
system.getDirectories = memoize((path: string) => original.getDirectories(path), path => path, copied)
try {
return run()
} finally {
for (const [name, descriptor] of descriptors) {
if (descriptor === undefined) Reflect.deleteProperty(system, name)
else Object.defineProperty(system, name, descriptor)
}
}
}
function main(): void {
// renderDocs reads the workspace only; generated pages are written after the memo is removed.
const docs = withMemoizedFileSystemProbes(renderDocs)
if (process.argv.includes('--check')) {
const stale: string[] = []
for (const doc of docs) {
const abs = resolve(root, doc.rel)
const committed = existsSync(abs) ? readFileSync(abs, 'utf8') : null
if (committed !== doc.content) stale.push(doc.rel)
}
if (stale.length === 0) {
console.log(`gen-doc-graphs: ${docs.length} graph doc(s) are up to date.`)
return
}
console.error(`gen-doc-graphs: stale graph doc(s): ${stale.join(', ')}. Run \`pnpm run gen-doc-graphs\` and commit the result.`)
process.exit(1)
}
for (const doc of docs) {
mkdirSync(dirname(resolve(root, doc.rel)), { recursive: true })
writeFileSync(resolve(root, doc.rel), doc.content)
}
console.log(`gen-doc-graphs: wrote ${docs.length} graph doc(s).`)
}
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
main()
}