diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d885ce4..9cdaa08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,5 +30,5 @@ jobs: run: node scripts/check-release.mjs - run: npm ci - run: npm run verify - - name: Publish core and Pi package with npm trusted publishing + - name: Publish core and integration packages with npm trusted publishing run: node scripts/publish.mjs diff --git a/.gitignore b/.gitignore index dda4009..982a173 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage/ integrations/pi/LICENSE .env.* !.env.example +integrations/dsh/LICENSE diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a2e7c..90f8566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Features + +- Add the `@chrok/dsh-braid` DeepSeek Harness plugin with background graphs, + isolated workers, revision-checked updates, pause/resume, cancellation, and + completion reminders through DSH's native model, tool, and job services. +- Add a native DSH Web panel with live graphs, execution history, paged outputs, + usage, and controls. Braid tool cards preserve recorded operation summaries + and open the corresponding job, node, or execution in the live panel. + ### Documentation - Introduce Braid with a parallel code-review example and clear installation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0149f3a..887feb0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,23 +15,28 @@ npm ci npm run verify ``` -The root npm workspace lockfile covers both packages. A development-only +The root npm workspace lockfile covers all packages. A development-only `@chrok/braid: file:.` dependency links the core checkout into `node_modules`; -Pi's manifest still declares the exact release version. This lets CI test a +Each integration manifest still declares the exact release version. This lets CI test a new core before it is published. `check:pi`, `test:pi`, and `build:pi` rebuild core so package imports resolve current JavaScript and declarations. Consumers -installing either published package do not install this development dependency. +installing published packages do not install this development dependency. Use `npm install --workspace @chrok/pi-braid ` for Pi dependency -updates; do not create a separate lockfile in `integrations/pi`. - -The core has no runtime dependencies. Pi's dependencies belong in its workspace -manifest. Update and commit the root lockfile when changing a dependency. Do not commit generated `dist` directories, +updates, or `npm install --workspace @chrok/dsh-braid ` for DSH. +Do not create separate lockfiles in integration directories. DSH uses +`check:dsh`, `test:dsh`, and `build:dsh` with the same core rebuild convention. +DSH Host and React Client use separate TypeScript programs to keep Cordis service +augmentations isolated. `build-client.mjs` emits the shared-React DSH loader factory; +`test:dsh` rebuilds it before testing the native Remote/slot lifecycle. + +The core has no runtime dependencies. Integration dependencies belong in their workspace +manifests. Update and commit the root lockfile when changing a dependency. Do not commit generated `dist` directories, tarballs, credentials, or provider output containing private data. -`verify` type-checks both packages, runs deterministic tests and offline examples, +`verify` type-checks core and both integrations, runs deterministic tests and offline examples, then builds tarballs and installs them into a temporary consumer outside the -checkout. That last check needs registry access for Pi dependencies but never -calls a model. `npm test` is the fast core-only loop; `npm run test:pi` tests Pi. +checkout. That last check needs registry access for integration dependencies but never +calls a model. `npm test` is the fast core-only loop; `npm run test:pi` tests Pi; `npm run test:dsh` tests DeepSeek Harness. `npm run bench` measures the scheduler without a provider. Run `npm run audit:dependencies` after `npm ci` to scan the complete dependency diff --git a/README.md b/README.md index a62a9b2..1834542 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Braid coordinates multiple LLM agents as a graph. Define each agent's task and which results it needs; Braid runs independent tasks in parallel and passes their outputs to the next step. -Use it as a TypeScript/JavaScript library or as an extension for the Pi coding agent. +Use it as a TypeScript/JavaScript library or as a plugin for Pi or DeepSeek Harness. [![CI](https://github.com/Epsirom/braid/actions/workflows/ci.yml/badge.svg)](https://github.com/Epsirom/braid/actions/workflows/ci.yml) [![npm core](https://img.shields.io/npm/v/%40chrok%2Fbraid?label=%40chrok%2Fbraid)](https://www.npmjs.com/package/@chrok/braid) @@ -37,6 +37,7 @@ and [execution control](docs/execution-control.md) when you need those features. | --- | --- | | In your own application | [Install `@chrok/braid`](#install-in-your-application) and supply a model runner | | In Pi | [Install `@chrok/pi-braid`](#install-in-pi) to run agents in the background and inspect them in a live panel | +| In DeepSeek Harness | [Install `@chrok/dsh-braid` from source](integrations/dsh/README.md#install) for background agents and a native Web graph panel | ## Install in your application @@ -548,6 +549,7 @@ is needed for the test suite; its HTTP requests are intercepted in tests. - [`src/workspaces.ts`](src/workspaces.ts): Git snapshots, checkpoint refs, merge serialization, local Git tools, and worktree cleanup. - [`src/adapters/openai.ts`](src/adapters/openai.ts): optional provider translation. +- [`integrations/dsh/`](integrations/dsh/): native DeepSeek Harness model/tool adapter, agent-scoped jobs, reminders, and a native Web graph panel. - [`integrations/pi/`](integrations/pi/): thin Pi model-registry/tool adapter, Mermaid graph renderer, and live execution renderer (`display.ts`). - [`test/`](test/): deterministic scheduling, execution-event, and intercepted HTTP/tool tests. diff --git a/docs/releasing.md b/docs/releasing.md index b62d75b..f90e0bf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,15 +1,17 @@ # Releasing -Core and Pi are separate public npm packages built from one commit. Both use the -same version. The core has no runtime dependencies. Pi declares an exact -`@chrok/braid` dependency and includes only its own compiled integration code. +Core, Pi, and DSH are separate public npm packages built from one commit. All use +the same version. The core has no runtime dependencies. Each integration declares +an exact `@chrok/braid` dependency and includes its own compiled integration code. ## Registry and source association The public packages are [@chrok/braid](https://www.npmjs.com/package/@chrok/braid) and [@chrok/pi-braid](https://www.npmjs.com/package/@chrok/pi-braid) on npmjs. -Keep `publishConfig.registry` set to `https://registry.npmjs.org`. Both manifests -link to `Epsirom/braid`; Pi's `repository.directory` is `integrations/pi`. +`@chrok/dsh-braid` is a new package awaiting its first publication. +Keep `publishConfig.registry` set to `https://registry.npmjs.org`. All manifests +link to `Epsirom/braid`; integration `repository.directory` values are +`integrations/pi` and `integrations/dsh`. The repository About website and README badges provide the reverse links. GitHub Packages is a separate registry and is not a mirror in this release flow. @@ -23,9 +25,10 @@ To inspect the public release without npm account credentials: ```sh npm view @chrok/braid dist-tags description repository homepage gitHead dist.attestations --json npm view @chrok/pi-braid dist-tags description repository homepage gitHead dist.attestations --json +npm view @chrok/dsh-braid dist-tags description repository homepage gitHead dist.attestations --json ``` -For a release, query the exact `@X.Y.Z` versions too. Confirm both `gitHead` +For a release, query the exact `@X.Y.Z` versions too. Confirm all `gitHead` values match the release commit, the source links point to this repository, and provenance identifies this repository's release workflow. The successful [Publish runs](https://github.com/Epsirom/braid/actions/workflows/release.yml) @@ -38,23 +41,23 @@ Version tags (`v*`) cannot be moved or deleted. New GitHub releases are immutabl prepare a draft and attach any assets before publishing. Published tag/asset corrections require a new version. See [repository settings](repository-settings.md). -1. Update both `package.json` versions, Pi's exact `@chrok/braid` dependency, +1. Update all `package.json` versions, both integrations' exact `@chrok/braid` dependencies, and the root workspace lockfile (`npm install --package-lock-only`). Use a minor version for breaking 0.x changes, and describe migrations in the changelog. 2. Run `npm ci` and `npm run verify` from the repository root. The package check verifies clean builds, public ESM imports, declarations, - licenses, and Pi registration in a temporary consumer outside the checkout. + licenses, Pi/DSH background jobs, and the DSH browser entry in temporary consumers outside the checkout. A temporary local registry serves the unpublished core tarball; installing - only the Pi tarball must fetch core transitively through its version dependency. + either integration tarball must fetch core transitively through its version dependency. 3. Update the changelog, migration guidance, package descriptions/keywords, and - supported Pi version. Reconcile README/Pi development notices and ROADMAP + supported Pi/DSH versions. Reconcile README/integration development notices and ROADMAP status with the release being prepared; mark publication complete only after - both packages are available. Commit and review the changes; + all packages are available. Commit and review the changes; require the CI matrix to pass before tagging that commit `vX.Y.Z`. -4. Inspect `npm pack --dry-run` and `npm pack --dry-run` from `integrations/pi`. +4. Inspect `npm pack --dry-run` from the root, `integrations/pi`, and `integrations/dsh`. `prepack` rebuilds each package. Never publish stale prebuilt output. 5. Prepare the GitHub release draft using the release-note policy below. After - publishing, verify both registry versions, `latest` tags, provenance, and a + publishing, verify all registry versions, `latest` tags, provenance, and a clean install before reporting the release complete. ## Release notes and contributor credit @@ -97,7 +100,7 @@ recovery codes private. Log in locally with `npm login --registry=https://registry.npmjs.org`; confirm the account with `npm whoami`. Publish the core with `npm publish --access public`, -then run `npm publish --access public` from `integrations/pi`. Complete npm's +then run `npm publish --access public` from each integration directory. Complete npm's interactive account/2FA checks if requested. Never put credentials in source, issues, shell history, or CI logs. An npm registration alone does not guarantee ownership of a previously used package name. @@ -108,7 +111,7 @@ Allow time for the exact versions to become available through `npm view`; do not republish or bump versions just to work around this delay. After availability is confirmed, install the registry versions in a fresh project and verify -both exported core entry points and the Pi extension. Add the actual publication +both exported core entry points and the Pi/DSH integrations. Add the actual publication date to the changelog and create the corresponding GitHub release. ## Subsequent releases with trusted publishing @@ -126,21 +129,23 @@ With npm 11.20 or later, the equivalent CLI setup is: ```sh npm trust github @chrok/braid --file release.yml --repo Epsirom/braid --allow-publish npm trust github @chrok/pi-braid --file release.yml --repo Epsirom/braid --allow-publish +npm trust github @chrok/dsh-braid --file release.yml --repo Epsirom/braid --allow-publish npm trust list @chrok/braid npm trust list @chrok/pi-braid +npm trust list @chrok/dsh-braid ``` Publishing a non-prerelease GitHub release triggers `.github/workflows/release.yml`. It validates the tag/version relationship, repeats all checks, and publishes -core then Pi using short-lived OIDC credentials. After each publish (or retry +core then Pi and DSH using short-lived OIDC credentials. After each publish (or retry of an existing version), it waits for matching version/commit metadata and a -successful tarball download. Pi is not published until core is available. npm +successful tarball download. Integrations are not published until core is available. npm processing is polled every 15 seconds for up to 10 minutes; a timeout fails the workflow with retry instructions. It does not require `NPM_TOKEN`. The workflow installs npm 11 and uses Node 24. See [npm's trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/). -If the second publish fails, rerun the same workflow after resolving the error. +If a later publish fails, rerun the same workflow after resolving the error. The script skips an existing version only when npm records the exact current Git commit as its `gitHead`. A collision from another commit fails explicitly; never unpublish/reuse a version to repair it. Publish a new patch instead. diff --git a/integrations/dsh/README.md b/integrations/dsh/README.md new file mode 100644 index 0000000..7fb4482 --- /dev/null +++ b/integrations/dsh/README.md @@ -0,0 +1,263 @@ +# Braid for DeepSeek Harness (`@chrok/dsh-braid`) + +A native DeepSeek Harness plugin for background Braid graphs, bounded loops, +isolated Git worktrees, live updates, pause/resume, and completion reminders. +It uses DSH's provider routing and credentials through `ctx.llm`; it has no Pi +runtime dependency. Tested against **DSH 0.2.0-rc.2** and Node.js **22.19+**. +DSH is in developer preview, so host peer versions are pinned to this baseline. + +## Install + +From this repository: + +```sh +npm ci +npm run build:dsh +dsh plugin --profile web add ./integrations/dsh +``` + +Restart the DSH profile after installation or rebuilding. The package includes +`dsh.bundle` metadata and `cordis.patch.yml`, so installation activates the plugin. +It requires the `tools`, `llm`, `commands`, `systemPrompt`, and `jobs` services, +which the standard DSH composition provides. Custom profiles must provide them. + +Once published, the planned npm installation command is: + +```sh +dsh plugin --profile web add @chrok/dsh-braid +``` + +Use your own profile name in place of `web`. Core `@chrok/braid` is installed +automatically at the matching version. No API keys belong in plugin configuration; +configure models through DSH. A node's `model` is an exact `provider/model-id`, +including any additional slashes in the model ID; omitted overrides use the parent +agent's model captured at submission. + +## Tools and live control + +| Capability | DSH interface | +| --- | --- | +| Submit a graph | `braid({ goal, nodes, edges, promptTemplates?, loops?, options? })` | +| List this agent's jobs | `braid_status({})` | +| Read a job | `braid_status({ jobId })` | +| Read a specific invocation | `braid_status({ jobId, executionId })` | +| Read a node's latest invocation | `braid_status({ jobId, nodeId })` | +| Atomically edit/resume | `braid_update({ jobId, expectedRevision, ...patch })` | +| Resume without edits | `braid_resume({ jobId, expectedRevision, executionIds })` | +| Cancel | `braid_cancel({ jobId })` or `/braid cancel ` | +| Human progress snapshot | `/braid [jobId]`, `/braid list` | +| Graph, execution details and controls | **Braid** in the session header or right-sidebar guide | +| Generic job progress and cancellation | DSH's native **Jobs** panel | + +Submission immediately returns a short `job-1` handle, a canonical UUID, and +`nativeJobId` (for DSH's generic job tools/panel). Braid tools accept the short +handle or UUID, exactly. Handles are scoped to the live agent; another agent or +session cannot retrieve them. Native job IDs belong to DSH's separate registry. +Receipts echo accepted node types, policies, overrides, and edges without prompts. + +## Braid Web panel + +Click **Braid** in a session's header, or choose **Braid** from the right-sidebar +guide. Braid's five tool calls also render as clickable transcript cards: + +| Tool card | Recorded summary and navigation | +| --- | --- | +| `braid` | Submission receipt and node count; opens the submitted graph once the receipt arrives | +| `braid_status` | Recorded status, revision and execution progress; focuses a requested node/execution; list results provide a link for each job | +| `braid_update` | Before/after revision, upserted/removed nodes, edge counts and resumed executions; rejected updates are marked as not applied | +| `braid_resume` | Requested execution IDs and remaining pauses; a single execution links directly to its history | +| `braid_cancel` | Cancellation requested or job already finished; opens the original job | + +The main card opens the right-sidebar Braid tab in the call's owning session. +Cards retain the result recorded at the time of the call; the sidebar shows the +**current** graph and status. Raw arguments, results and errors remain accessible +under **Show tool details**, along with DSH's inspector when available. Failed +submissions and calls still preparing a new graph do not link to an unrelated +latest job. Short handles in older receipts are fenced by the call timestamp; +a job missing after a Host restart is reported as unavailable. + +The panel is a native DSH tab: the sidebar provides docking, splitting, +floating and fullscreen. It follows the session that owns the tab. + +- Select a Braid job and inspect its live graph. Nodes show their type and state; + edge labels show decisions, and dashed back-edges show bounded loops. Zoom and + scrolling accommodate larger graphs. +- Click a node to select an exact execution, including earlier loop iterations. + Inspect output, decisions, errors, context usage, tool counts, latency, workspace + details and recovery refs. Output is paged in 32,768-character chunks. +- View the latest 80 lifecycle events and input/output/cache token totals. +- Cancel the job, resume one paused execution, or resume all paused executions. + Resume submits the revision currently displayed; stale revisions are rejected + and surfaced in the panel. Editing a graph remains a model-tool operation. + +The Host sends structured snapshots through DSH's authenticated Remote transport, +coalesced to at most one update per 150 ms per observer. Prompts, provider messages +and node outputs are excluded from that stream; selected outputs are read +separately. Switching sessions or closing the tab stops observation, while jobs +continue. Disconnected controls are disabled; the panel retries the stream, then +offers manual reconnect if retries fail. + +The package declares `dsh.client` and exports `./client`, a DSH module-loader +factory sharing the shell's React instance. The Web profile must provide +`typert`, `remote`, the slot renderer, conversation header, tool views and right sidebar. +Custom headless profiles can omit these services and retain the tools/commands. +Unloading the plugin removes its tab type, slots, styles and Remote methods. + +The generic Jobs panel also streams a text flow diagram, node states, elapsed times, context +token estimates/provider reports, context capacity when available, tool counts, +and recent events. Its own stop action cancels the Braid run. Closing the panel +leaves it running. `/braid` returns a snapshot in the command UI; it does not open +Pi's terminal overlay or bind Pi's keyboard shortcuts. Headless profiles can use +the tools and commands without a Web UI. + +The five graph tools expose the same execution controls as the Pi integration. +DSH presentation uses a native Web graph panel, jobs and commands; it does not +include Pi's Mermaid terminal renderer. Tool results are canonical JSON for both native and PTC mode. + +## Graphs and workspaces + +```json +{ + "goal": "Implement a fix, review it, and apply it", + "nodes": [ + { "type": "execute", "id": "implement", "prompt": "Implement and test the fix." }, + { "type": "execute", "id": "review", "prompt": "Review the implementation and report evidence.", "workspace": "read-only", "pauseAfter": true }, + { "type": "integrate", "id": "apply", "requireSuccess": true } + ], + "edges": [ + { "from": "implement", "to": "review" }, + { "from": "review", "to": "apply" } + ] +} +``` + +`execute` and `decision` require `prompt`; only decisions declare nonempty, +distinct `choices` and call `decide`. `merge` and `integrate` allow an optional +prompt and forbid `workspace`/`choices`. All four types accept `model`, +`notifyOnCompletion`, `pauseAfter`, and `requireSuccess`. Core validates every +submission and update before it takes effect. + +Inside Git, each execution gets a fresh snapshot of its predecessor checkpoints; +roots include tracked and non-ignored untracked caller changes. Execute/decision +nodes default to writable worktrees. `workspace: "read-only"` keeps snapshot +isolation while removing write/edit/shell tools. Outside Git all workers are +read-only. Independent changed snapshots need an explicit merge node before +another ordinary worker can consume their combined code. + +`merge` combines snapshots in an isolated worktree. `integrate` applies selected +changes to the invoking checkout, preserving user edits. Nothing automatically +integrates at job completion. Both expose constrained Git operations and require +`finish_merge` dispositions for every source execution ID. Checkpoints and +integration backup refs remain recoverable after worktree cleanup. A failed +integration can leave partial changes or conflicts; core never resets the caller. + +Workers receive a fresh DSH context containing the goal, rendered node prompt, +direct predecessor outputs, execution identity, and workspace/merge metadata. +They do not inherit the parent transcript, skills, extension/MCP tools, or Braid +itself. Fixed local tools are: + +- `read` and `ls`; `grep` and `find` when `rg` is installed. Missing search + dependencies are not downloaded; use `ls`/`read` instead. +- `write`, `edit`, and `bash` for writable Git workspaces; `powershell` on Windows. +- `git` when core provides Git access; `finish_merge` for merge/integrate nodes. +- `decide` only for decision nodes, with exactly one declared choice. + +Guarded writes reject paths outside the assigned workspace, Git metadata, +submodules, symlinks, hard links, and special files. Shell commands use host +permissions and can bypass file guards; worktrees are not an OS sandbox. Workers +are instructed to keep writes local, preserve shared Git state, and avoid global +or externally visible actions unless requested. Commands run in the foreground; +completion, timeout, and cancellation terminate their process group. Ignored new +dependencies, caches, and build outputs are not checkpointed and disappear during +cleanup. Shared services and ports require execution-specific resources. + +## Pauses, updates, loops, and reminders + +`notifyOnCompletion: true` sends an intermediate success/failure reminder. +`pauseAfter: true` additionally holds outgoing scheduling; independent branches +continue. Reminders identify the exact invocation and loop iteration in the job +data. Fetch `braid_status`, then use its **current** `execution.revision` and +`execution.pausedExecutionIds`: + +```json +{ + "jobId": "job-1", + "expectedRevision": 0, + "executionIds": [""] +} +``` + +Pass this to `braid_resume`. `braid_update` accepts complete `upsertNodes`, +`removeNodeIds`, `addEdges`, `removeEdges`, `promptTemplates`, `loops`, and +`resume`. Use historical `edge.executionId` pins only in updates. Include new +nodes and their dependencies in the same update: a disconnected root can start +immediately. Rejected revisions/patches change nothing. Upserts replace whole +node definitions; supplied templates/loops replace their whole map/list. +`removeEdges` matches every identity field exactly; omitted fields are not +wildcards. Existing invocations retain their captured inputs and policies. +Finalized jobs cannot be reopened. + +Bounded loops use a `loops` entry `{ id, entry, maxIterations }` and one feedback +edge from a decision to that entry carrying `choice` and `feedback: ""`. +`maxIterations` includes the first round. Loops cannot overlap or nest. Prompt +templates use `{{name}}` placeholders and node prompts such as +`{ "template": "review", "variables": { "target": "src/runtime.ts" } }`. +Variable names must match exactly; values are inserted literally. +See the [execution-control guide](https://github.com/Epsirom/braid/blob/main/docs/execution-control.md) +for the shared graph semantics. + +Completion and pause messages use DSH `agent.steer`: a running agent consumes +them at the next step, and an idle agent wakes. Acknowledged messages are removed; +discarded messages retry when the agent returns to idle. DSH's generic job +controller may also show its native job completion notice. Retrieve results after +the reminder instead of polling. Stopping a foreground response does not cancel +submitted jobs. Plugin or owning-agent disposal aborts workers, awaits cleanup, +and suppresses Braid reminders. Switching the UI to another still-live agent does +not dispose the original owner or redirect its reminders. Jobs are in memory; +they do not survive a DSH restart. + +## Budgets and accounting + +`options` accepts `maxConcurrency` (default 4), `maxExecutions` (default 1000), +`maxToolRounds`, `maxToolCalls`, `nodeTimeoutMs`, and `graphTimeoutMs`. Tool/time +budgets are unlimited when omitted. Limits count rejected tool calls too. Workers +receive remaining budget/deadline reminders before model requests. Graph deadlines +continue during pauses and do not reset on resume or update. + +`braid_status.usage` aggregates DSH usage, including completed provider rounds +from workers that later fail or time out. Input/cache fields are disjoint; +`totalTokens` includes input, output, cache reads, and cache writes. Core runner +usage combines input/cache into `inputTokens`. DSH does not offer Pi's external +tool-cost accounting contract: repeated status reads return the same totals +without adding charges to the foreground agent. No dollar cost is inferred. + +Results over 50 KB are saved as private JSON files in the host's temporary +directory. The bounded response preserves status/control fields and includes +`fullOutputPath`, including for running graphs and focused execution reads. +Running status retains the latest 80 events; finalized `result.events` contains +the complete core event log. Saved files remain available after job cleanup. + +## Development + +```sh +npm run check:dsh +npm run test:dsh +npm run build:dsh +npm run test:package +``` + +Tests use scripted providers without network model calls. They exercise real +Cordis/DSH registration, native job ownership/control, canonical tool outputs, +reminder delivery and cleanup, graph controls, loops, budgets, filesystem guards, +and process cancellation. Web tests cover the native Gateway, the built browser +factory and Remote mounting, slot registration/unload, session switching, safe +text rendering, controls, revision checks, paging and disconnect cleanup. Package +smoke tests install the packed package outside the checkout, load its browser +factory and run a background worker. All dependencies share the root lockfile. +Use `npm install --workspace @chrok/dsh-braid ` for dependency updates. + +Host contracts: [DSH plugin packaging](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.0-rc.2/docs/user/develop/basic/publish.md), +[tool authoring](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.0-rc.2/docs/cookbook/adding-a-tool.md), +[client modules](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.0-rc.2/docs/subsystems/client-modules.md), +[right-sidebar extensions](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.0-rc.2/packages/client/ui-sidebar-right/README.md), +and [native jobs](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.0-rc.2/packages/jobs/jobs/README.md). diff --git a/integrations/dsh/build-client.mjs b/integrations/dsh/build-client.mjs new file mode 100644 index 0000000..7c5856e --- /dev/null +++ b/integrations/dsh/build-client.mjs @@ -0,0 +1,15 @@ +import { build } from "esbuild"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +const directory = fileURLToPath(new URL(".", import.meta.url)); +const result = spawnSync(process.execPath, [fileURLToPath(new URL("../../node_modules/typescript/bin/tsc", import.meta.url)), + "-p", `${directory}tsconfig.client.json`, "--noEmit", "false", "--emitDeclarationOnly"], { stdio: "inherit" }); +if (result.error) throw result.error; +if (result.status !== 0) process.exit(result.status ?? 1); +// DSH loads a closure factory, with React supplied by its shared module table. +await build({ entryPoints: [`${directory}client/index.tsx`], outfile: `${directory}dist/client.js`, + bundle: true, platform: "browser", format: "cjs", target: "es2022", jsx: "automatic", sourcemap: true, + external: ["react", "react/jsx-runtime"], + banner: { js: 'window.__ModuleLoader__.load({ id: "@chrok/dsh-braid", factory: (require) => { var module = { exports: {} }; var exports = module.exports;' }, + footer: { js: "return module.exports; } });" }, +}); diff --git a/integrations/dsh/client/Panel.tsx b/integrations/dsh/client/Panel.tsx new file mode 100644 index 0000000..61000d4 --- /dev/null +++ b/integrations/dsh/client/Panel.tsx @@ -0,0 +1,167 @@ +import * as React from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import type { PanelApi, PanelDetail, PanelExecution, PanelFrame, PanelJob } from "../panel-types.js"; +import { layoutGraph } from "./layout.js"; +import type { BraidNavigation } from "./navigation.js"; + +const number = (n: number) => n.toLocaleString(); +const message = (error: unknown) => error instanceof Error ? error.message : String(error); +export function Status({ status }: { status: string }) { + return {status}; +} + +export function Graph({ job, selected, select }: { job: PanelJob; selected: string; select: (id: string) => void }) { + const layout = useMemo(() => layoutGraph(job.nodes, job.edges), [job.nodes, job.edges]); + const [zoom, setZoom] = useState(1); + const arrow = useId().replaceAll(":", ""); + const selectedButton = useRef(null); + useEffect(() => { selectedButton.current?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); }, [selected]); + return
+

Execution graph

+ + + +
+
+
+ + {job.nodes.map(node => { const p = layout.positions.get(node.id)!; return ; })} +
+
+
Solid · dependencyDashed · loop feedbackClick a node to inspect
+ {job.loops.map(loop =>

Loop {loop.id} · entry {loop.entry} · maximum {loop.maxIterations} iterations

)} +
; +} + +function ExecutionDetail({ api, sessionId, job, execution }: { api: PanelApi; sessionId: string; job: PanelJob; execution: PanelExecution }) { + const [detail, setDetail] = useState(); + const [error, setError] = useState(""); + const [offset, setOffset] = useState(0); + const [retry, setRetry] = useState(0); + useEffect(() => { + let active = true; + setDetail(undefined); setError(""); + void api.detail({ sessionId, jobId: job.jobId, executionId: execution.executionId, offset }).then(value => { + if (active) setDetail(value); + }, e => { if (active) setError(message(e)); }); + return () => { active = false; }; + }, [api, sessionId, job.jobId, job.status, execution.executionId, execution.status, offset, retry]); + const p = execution.progress; + return <> +
+
Execution{execution.executionId}
Revision / iterationr{execution.revision} / {execution.iteration ?? "—"}
+
Elapsed{execution.latencyMs === undefined ? "—" : `${(execution.latencyMs / 1000).toFixed(1)}s`}
+
Worker{p?.phase ?? execution.status}
+
Context ({p?.contextSource ?? "unreported"}){p ? `${number(p.contextTokens)}${p.contextWindow ? ` / ${number(p.contextWindow)}` : ""}` : "—"}
+
Tool calls / rounds{p ? `${p.toolCalls} / ${p.toolRounds}` : "—"}
+
+ {error &&
{error}
} + {!detail && !error &&

Loading output…

} + {detail && <> + {detail.error &&

{detail.error}

} + {detail.decision &&

Decision: {detail.decision}

} +
{detail.output || (execution.status === "running" ? "Output will appear when this execution finishes." : "No output for this execution.")}
+ {detail.total > 32768 &&
{number(detail.offset)}–{number(detail.next)} / {number(detail.total)} characters
} + {detail.workspace &&
Workspace and recovery refs
{detail.workspace}
} + } + ; +} + +function JobView({ job, api, sessionId, connected, act, busy, target }: { job: PanelJob; api: PanelApi; sessionId: string; connected: boolean; act: (action: "cancel" | "resume", ids: string[]) => void; busy: boolean; target: BraidNavigation | undefined }) { + const [selected, setSelected] = useState(() => job.executions.find(e => e.executionId === target?.executionId)?.id + ?? target?.nodeId ?? (target?.executionId ? "" : job.nodes[0]?.id ?? "")); + const [chosenExecution, setChosenExecution] = useState(target?.executionId ?? ""); + const [tab, setTab] = useState("output"); + const executions = job.executions.filter(e => e.id === selected); + const execution = chosenExecution ? executions.find(e => e.executionId === chosenExecution) : executions.at(-1); + const paused = execution && job.pausedExecutionIds.includes(execution.executionId); + return <> +
{job.executions.filter(e => e.status === "completed").length}/{job.executions.length}Executions complete
{number(job.usage.inputTokens)}Input tokens
{number(job.usage.outputTokens)}Output tokens
{number(job.usage.cacheReadTokens + job.usage.cacheWriteTokens)}Cache tokens
+ {job.error &&
{job.error}
} + {job.pausedExecutionIds.length > 0 &&
{job.pausedExecutionIds.length} execution(s) paused

Inspect the output, then resume outgoing scheduling. Job deadlines continue while paused.

} + { setSelected(id); setChosenExecution(""); setTab("output"); }}/> +

{selected || "Execution details"}

{execution && }
+ {chosenExecution && !execution &&

The requested execution ({chosenExecution}) is no longer available. Select a node to inspect its current history.

} + {executions.length > 0 ? :

This node has not run yet.

} + {paused && } +
+ {tab === "output" && execution && } + {tab === "events" && <>

Latest 80 lifecycle events. Full results remain available through braid_status.

    {[...job.events].reverse().map(event =>
  1. {event.message}

    #{event.sequence}{event.nodeId ? ` · ${event.nodeId}` : ""}{event.executionId ? ` · ${event.executionId}` : ""}
  2. )}
} +

Cache read {number(job.usage.cacheReadTokens)} · cache write {number(job.usage.cacheWriteTokens)}. Usage is reported by DSH; monetary cost is not estimated.

+
+ ; +} + +/** The session key deliberately resets every local selection and request generation. */ +export function BraidPanelView(props: { sessionId: string; api: PanelApi; target?: BraidNavigation }) { + return ; +} +function SessionPanel({ sessionId, api, target }: { sessionId: string; api: PanelApi; target?: BraidNavigation }) { + const [jobId, setJobId] = useState(target?.jobId); + const [createdBefore, setCreatedBefore] = useState(target?.createdBefore); + const [frame, setFrame] = useState(); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(""); + const [actionError, setActionError] = useState(""); + const [busy, setBusy] = useState(false); + const [retry, setRetry] = useState(0); + useEffect(() => { + const controller = new AbortController(); + setConnected(false); setFrame(undefined); setError(""); setActionError(""); + void (async () => { + for (let attempt = 0; !controller.signal.aborted; attempt++) { + try { + for await (const value of api.watch({ sessionId, ...(jobId ? { jobId } : {}), ...(createdBefore === undefined ? {} : { createdBefore }) }, controller.signal)) { + if (controller.signal.aborted) return; + setFrame(value); setConnected(true); setError(""); attempt = 0; + } + if (!controller.signal.aborted) throw new Error("Braid connection closed"); + } catch (e) { + if (controller.signal.aborted) return; + setConnected(false); setError(`${message(e)}${attempt < 2 ? " · reconnecting…" : ""}`); + if (attempt >= 2) return; + await new Promise(resolve => { + const done = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", done); resolve(); }; + const timer = setTimeout(done, 1000 * 2 ** attempt); + controller.signal.addEventListener("abort", done, { once: true }); + }); + } + } + })(); + return () => { controller.abort(); }; + }, [api, sessionId, jobId, createdBefore, retry]); + const job = frame?.job; + // In-flight mutations only affect their mounted job view; stream frames remain authoritative. + const [actionOwner, setActionOwner] = useState(""); + const act = async (action: "cancel" | "resume", executionIds: string[]) => { + if (!job || !connected || busy) return; + const owner = job.jobId; + setBusy(true); setActionOwner(owner); setActionError(""); + try { await api.control({ sessionId, jobId: owner, action, executionIds, revision: job.revision }); } + catch (e) { setActionError(message(e)); } + finally { setBusy(false); } + }; + return
+
Agent orchestration

Braid

{connected ? "● Live" : error ? "○ Disconnected" : "Connecting…"}
+ {frame && frame.rows.length > 0 && <> + {job && <>

{job.goal}

r{job.revision} · {job.nodes.length} nodes
} + } +
+ {error &&
{error}
} + {actionError && actionOwner === job?.jobId &&
{actionError}
} + {connected && frame?.rows.length === 0 &&

No Braid jobs in this session

Ask the agent to use Braid for a parallel or multi-step task. Its graph, execution history and results will appear here.

} + {job && void act(action, ids)} busy={busy} + target={target?.jobId && [job.jobId, job.handle].includes(target.jobId) ? target : undefined}/>} +
; +} diff --git a/integrations/dsh/client/ToolCall.tsx b/integrations/dsh/client/ToolCall.tsx new file mode 100644 index 0000000..7ae2bd8 --- /dev/null +++ b/integrations/dsh/client/ToolCall.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import type { ToolCallOwnerProps } from "@deepseek-ai/dsh-client-ui-tool/client"; +import { toolNavigation, toolPresentation, type BraidNavigation } from "./navigation.js"; + +export type BraidToolCallProps = ToolCallOwnerProps & { openBraid: (target: BraidNavigation) => void }; + +/** The main row opens the graph; a separate disclosure preserves the raw tool evidence. */ +export function BraidToolCall(props: BraidToolCallProps) { + const { toolName, phase, block, openBraid, useDisclosure, inspect } = props; + const { expanded, toggle } = useDisclosure(); + const target = toolNavigation(toolName, props); + const model = toolPresentation(toolName, props); + const failed = phase === "result" && block.isError; + const state = failed ? "failed" : phase === "result" ? "completed" : phase === "preparing" ? "preparing" : "running"; + const raw = phase === "result" ? block.call?.argsRaw : phase === "start" ? block.argsRaw : undefined; + const output = phase === "result" ? block.content.filter(c => c.type === "text").map(c => c.text).join("\n") : ""; + return
+ + {model.changes.length > 0 &&
{model.changes.map((change, i) => {change})}
} + {model.jobs.length > 0 &&
    {model.jobs.map(job =>
  • )}
} + {phase === "result" && target &&

Recorded result · open the graph for live state

} + {failed &&

{block.error?.reason ?? block.error?.code ?? (output.slice(0, 600) || "Braid tool call failed. Expand the result for details.")}

} +
+ {inspect && } +
+ {expanded &&
Arguments
{raw ?? "Arguments are being prepared…"}
+ {phase === "result" && <>Result
{output || "No text result."}
} +
} +
; +} diff --git a/integrations/dsh/client/index.tsx b/integrations/dsh/client/index.tsx new file mode 100644 index 0000000..13cbb20 --- /dev/null +++ b/integrations/dsh/client/index.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-api-gateway/client"; +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; +import type {} from "@deepseek-ai/dsh-client-ui-sidebar-right/client"; +import type {} from "@deepseek-ai/dsh-client-ui-tool/client"; +import type { PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { PanelApi } from "../panel-types.js"; +import { PANEL_PACKAGE, panelDescriptors } from "../panel-contract.js"; +import { BraidPanelView } from "./Panel.js"; +import { styles, toolStyles } from "./styles.js"; +import { BraidToolCall } from "./ToolCall.js"; +import { braidToolNames, readNavigation, type BraidNavigation } from "./navigation.js"; + +declare module "@deepseek-ai/dsh-client-ui-sidebar-right/client" { + interface SidebarRightTabParamsMap { braid: BraidNavigation } +} + +export const inject = ["remote", "slots", "sidebarRight", "sidebarRightTabs"]; +export async function apply(ctx: Context): Promise { + await ctx.remote.$mount({ package: PANEL_PACKAGE, descriptors: panelDescriptors }); + ctx.inject(["remote.braidPanel"], ctx => { + // Capture traced services inside their declared Cordis scope, not React event stacks. + const remote = ctx.remote.braidPanel; + const sidebar = ctx.sidebarRight; + const api: PanelApi = { + watch: (request, signal) => remote.watch(request, signal), + control: async request => { const result = await remote.control(request); if (!result.ok) throw result.error; return result.value; }, + detail: async request => { const result = await remote.detail(request); if (!result.ok) throw result.error; return result.value; }, + }; + ctx.effect(() => { + const style = document.createElement("style"); style.dataset.plugin = PANEL_PACKAGE; style.textContent = styles + toolStyles; + document.head.append(style); return () => { style.remove(); }; + }); + ctx.effect(() => ctx.sidebarRightTabs.register({ id: PANEL_PACKAGE, kind: "braid", title: () => "Braid", + guide: [{ id: "braid", order: 50, title: () => "Braid", description: () => "Live agent graphs, execution details and controls" }], + })); + ctx.effect(() => ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register({ + name: "sidebar.right.pane.tab", key: PANEL_PACKAGE, inject: sessionId => ({ api, sessionId }), + }, PanelSeat))); + ctx.effect(() => ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({ + name: "conversation.session.header.actions", id: "braid", order: 25, + inject: () => ({ open: () => { sidebar.openTab("braid"); } }), + }, HeaderAction))); + for (const key of braidToolNames) ctx.effect(() => ctx.slots.inject("tool.call.toolview", () => ctx.slots.register({ + name: "tool.call.toolview", key, + inject: sessionId => ({ openBraid: (target: BraidNavigation) => { sidebar.openTabIn(sessionId as SessionId, "braid", { params: target }); } }), + }, BraidToolCall))); + }); +} +function HeaderAction({ open }: { open: () => void }) { + return ; +} +function PanelSeat({ sessionId, api, useTabInfo }: PropsRuntime<"sidebar.right.pane.tab"> & { sessionId: string; api: PanelApi }) { + const { tab: { navigation } } = useTabInfo(); + return ; +} diff --git a/integrations/dsh/client/layout.ts b/integrations/dsh/client/layout.ts new file mode 100644 index 0000000..b14ab82 --- /dev/null +++ b/integrations/dsh/client/layout.ts @@ -0,0 +1,27 @@ +import type { PanelJob } from "../panel-types.js"; + +/** Longest-path ranks in the forward DAG. Feedback is drawn separately. */ +export function layoutGraph(nodes: PanelJob["nodes"], edges: PanelJob["edges"]) { + const rank = new Map(nodes.map(node => [node.id, 0])); + const incoming = new Map(nodes.map(node => [node.id, 0])); + const outgoing = new Map(nodes.map(node => [node.id, [] as string[]])); + for (const edge of edges) { + if (edge.feedback || !rank.has(edge.from) || !rank.has(edge.to)) continue; + incoming.set(edge.to, incoming.get(edge.to)! + 1); + outgoing.get(edge.from)!.push(edge.to); + } + const queue = nodes.filter(node => incoming.get(node.id) === 0).map(node => node.id); + for (let i = 0; i < queue.length; i++) for (const to of outgoing.get(queue[i]!)!) { + rank.set(to, Math.max(rank.get(to)!, rank.get(queue[i]!)! + 1)); + incoming.set(to, incoming.get(to)! - 1); + if (incoming.get(to) === 0) queue.push(to); + } + const columns = new Map(); + const positions = new Map(nodes.map(node => { + const row = rank.get(node.id)!, column = columns.get(row) ?? 0; + columns.set(row, column + 1); + return [node.id, { x: 32 + column * 186, y: 28 + row * 124 }]; + })); + return { positions, width: Math.max(280, ...[...columns.values()].map(count => count * 186 + 32)), + height: Math.max(160, ...[...rank.values()].map(row => (row + 1) * 124 + 28)) }; +} diff --git a/integrations/dsh/client/navigation.ts b/integrations/dsh/client/navigation.ts new file mode 100644 index 0000000..bb355d1 --- /dev/null +++ b/integrations/dsh/client/navigation.ts @@ -0,0 +1,113 @@ +import type { ToolCallPhaseProps } from "@deepseek-ai/dsh-client-ui-tool/client"; + +export interface BraidNavigation { jobId?: string; nodeId?: string; executionId?: string; createdBefore?: number } +export const braidToolNames = ["braid", "braid_status", "braid_update", "braid_resume", "braid_cancel"] as const; + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} +function json(raw: string): Record { + try { return record(JSON.parse(raw)); } catch { return {}; } +} +const id = (value: unknown): string | undefined => typeof value === "string" && value.trim() && value.length <= 1024 ? value : undefined; + +/** Sidebar navigation also survives layout restoration, so validate its loose JSON boundary. */ +export function readNavigation(value: unknown): BraidNavigation { + const source = record(value), target: BraidNavigation = {}; + for (const key of ["jobId", "nodeId", "executionId"] as const) { + const text = id(source[key]); if (text !== undefined) target[key] = text; + } + if (typeof source.createdBefore === "number" && Number.isSafeInteger(source.createdBefore) && source.createdBefore >= 0) + target.createdBefore = source.createdBefore; + return target; +} + +function toolData(props: ToolCallPhaseProps) { + const args = json(props.phase === "result" ? props.block.call?.argsRaw ?? "" : props.phase === "start" ? props.block.argsRaw : ""); + let result: Record = {}; + if (props.phase === "result" && !props.block.isError) for (const content of props.block.content) { + if (content.type !== "text") continue; + const value = json(content.text); + if (Object.keys(value).length > 0) { result = value; break; } + } + return { args, result }; +} + +/** Read only envelope identifiers, never the result preview, prompts, or worker output. */ +export function toolNavigation(toolName: string, props: ToolCallPhaseProps): BraidNavigation | null { + if (!braidToolNames.some(name => name === toolName) || props.phase === "preparing") return null; + const { args, result } = toolData(props); + const jobId = id(result.canonicalJobId) ?? id(result.jobId) ?? id(args.jobId); + // A submission has no target until its receipt arrives. A list call opens the roster. + if (!jobId) return toolName === "braid_status" && !("jobId" in args || "nodeId" in args || "executionId" in args) ? {} : null; + const target: BraidNavigation = { jobId }; + const node = record(result.node); + const executionId = id(args.executionId) ?? id(node.executionId) + ?? (toolName === "braid_resume" && Array.isArray(args.executionIds) && args.executionIds.length === 1 ? id(args.executionIds[0]) : undefined); + const nodeId = id(args.nodeId) ?? id(node.id); + if (executionId) target.executionId = executionId; + if (nodeId) target.nodeId = nodeId; + const time = props.block.time; + if (Number.isSafeInteger(time) && time >= 0) target.createdBefore = time; + return target; +} + +export interface ToolPresentation { + label: string; + summary: string; + changes: string[]; + jobs: { label: string; status: string; target: BraidNavigation }[]; +} +const array = (value: unknown): unknown[] => Array.isArray(value) ? value : []; +const revision = (value: unknown): string => typeof value === "number" && Number.isSafeInteger(value) ? `r${value}` : ""; +const names = (values: unknown[]): string => { + const ids = values.map(value => id(value) ?? id(record(value).id)).filter(Boolean); + return ids.slice(0, 8).join(", ") + (ids.length > 8 ? ` … (+${ids.length - 8})` : ""); +}; + +/** Each tool card describes its recorded operation; the sidebar is explicitly live. */ +export function toolPresentation(toolName: string, props: ToolCallPhaseProps): ToolPresentation { + const { args, result } = toolData(props); + const label = id(result.handle) ?? id(args.jobId) ?? id(result.jobId) ?? ""; + const model: ToolPresentation = { label, summary: "", changes: [], jobs: [] }; + const failed = props.phase === "result" && props.block.isError; + const settled = props.phase === "result"; + if (toolName === "braid_update") { + const revisions = [revision(args.expectedRevision), revision(result.revision)].filter(Boolean).join(" → "); + model.summary = `${failed ? "Update rejected · not applied" : settled ? "Graph updated" : "Updating graph"}${revisions ? ` · ${revisions}` : ""}`; + for (const [key, title] of [["upsertNodes", "Upsert nodes"], ["removeNodeIds", "Remove nodes"], ["resume", "Resume executions"]] as const) { + const values = array(args[key]); if (values.length) model.changes.push(`${title}: ${names(values)}`); + } + for (const [key, title] of [["addEdges", "Add edges"], ["removeEdges", "Remove edges"]] as const) { + const values = array(args[key]); if (values.length) model.changes.push(`${title}: ${values.length}`); + } + if ("loops" in args) model.changes.push(`Replace loops: ${array(args.loops).length}`); + if ("promptTemplates" in args) model.changes.push(`Prompt templates: ${Object.keys(record(args.promptTemplates)).length}`); + } else if (toolName === "braid_resume") { + model.summary = failed ? "Resume rejected" : `${settled ? "Resumed" : "Resuming"} ${array(args.executionIds).length} execution(s)`; + const ids = names(array(args.executionIds)); if (ids) model.changes.push(ids); + if (Array.isArray(result.pausedExecutionIds)) model.changes.push(`${result.pausedExecutionIds.length} still paused · ${revision(result.revision)}`); + } else if (toolName === "braid_cancel") { + model.summary = failed ? "Cancellation failed" : !settled ? "Requesting cancellation" : result.cancelled === false ? "Job already finished" : "Cancellation requested"; + } else if (toolName === "braid_status" && Array.isArray(result.jobs)) { + model.summary = `${result.jobs.length} job(s) at this call`; + model.jobs = result.jobs.flatMap(value => { + const row = record(value), jobId = id(row.jobId); + return jobId ? [{ label: id(row.handle) ?? jobId, status: id(row.status) ?? "", target: { jobId, createdBefore: props.block.time } }] : []; + }); + } else if (toolName === "braid_status") { + const execution = record(result.execution), node = record(result.node); + const executions = Object.values(record(execution.executions)); + const details = id(node.status) + ? [`execution ${id(node.status)}`, id(execution.status) ? `graph ${id(execution.status)}` : undefined, revision(execution.revision)] + : [id(execution.status) ?? id(result.status), revision(execution.revision)]; + if (executions.length) details.push(`${executions.filter(value => record(value).status === "completed").length}/${executions.length} executions complete`); + if (Array.isArray(execution.pausedExecutionIds) && execution.pausedExecutionIds.length) details.push(`${execution.pausedExecutionIds.length} paused`); + model.summary = failed ? "Status query failed" : details.filter(Boolean).join(" · ") || (settled ? "Recorded status" : "Reading status"); + } else { + const graph = record(result.graph), nodes = array(graph.nodes).length || array(args.nodes).length; + model.summary = failed ? "Submission failed" : `${settled ? "Graph submitted" : "Starting graph"}${nodes ? ` · ${nodes} nodes` : ""}`; + } + if (result.truncated === true) model.changes.push("Large result saved to file; view fullOutputPath in tool details"); + return model; +} diff --git a/integrations/dsh/client/styles.ts b/integrations/dsh/client/styles.ts new file mode 100644 index 0000000..c318d5f --- /dev/null +++ b/integrations/dsh/client/styles.ts @@ -0,0 +1,20 @@ +/** Scoped styles share the Host's fonts and honor both system and DSH dark themes. */ +export const styles = ` +.braid-panel{--br-bg:var(--dsw-alias-bg-base,#fff);--br-fg:var(--dsw-alias-label-primary,#222b38);--br-soft:var(--dsw-alias-bg-module-platform,#f4f6f8);--br-line:var(--dsw-alias-border-l2,#dce2e8);--br-muted:var(--dsw-alias-label-secondary,#667384);--br-accent:#336dbe;--br-green:var(--dsw-alias-state-success-primary,#18794e);--br-red:var(--dsw-alias-state-error-primary,#ba3340);color:var(--br-fg);background:var(--br-bg);font:inherit;font-size:13px;line-height:1.5;box-sizing:border-box;height:100%;overflow:auto;min-width:0;container-type:inline-size} +.braid-panel *{box-sizing:border-box}.braid-panel button,.braid-panel select{font:inherit;color:inherit}.braid-panel button,.braid-panel select{border:1px solid var(--br-line);border-radius:7px;background:var(--br-bg);padding:6px 10px}.braid-panel button{cursor:pointer}.braid-panel button:hover:not(:disabled){border-color:var(--br-accent)}.braid-panel button:focus-visible,.braid-panel select:focus-visible{outline:2px solid var(--br-accent);outline-offset:2px}.braid-panel button:disabled{opacity:.45;cursor:default}.braid-panel .br-primary{background:var(--br-accent);color:white;border-color:transparent}.braid-panel .br-danger{color:var(--br-red)} +.br-header{padding:20px 18px 12px;border-bottom:1px solid var(--br-line)}.br-heading{display:flex;align-items:center;gap:9px}.br-heading h2{font-size:18px;letter-spacing:-.4px;margin:0}.br-heading small{margin-left:auto;color:var(--br-muted)}.br-eyebrow{text-transform:uppercase;letter-spacing:1.4px;font-size:10px;color:var(--br-muted);margin-bottom:5px}.br-select{width:100%;margin:14px 0 8px}.br-goal{margin:8px 0;overflow-wrap:anywhere}.br-muted{color:var(--br-muted)}.br-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.br-status{display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}.br-status:before{content:'';width:6px;height:6px;border-radius:50%;background:var(--br-muted)}.br-status[data-status=running]:before,.br-status[data-status=runnable]:before{background:var(--br-accent)}.br-status[data-status=completed]:before{background:var(--br-green)}.br-status[data-status=failed]:before,.br-status[data-status=cancelled]:before{background:var(--br-red)}.br-status[data-status=waiting]:before{background:#ba861a} +.br-stats{display:grid;grid-template-columns:repeat(4,1fr);padding:14px 18px;gap:8px;border-bottom:1px solid var(--br-line)}.br-stats strong{display:block;font-size:16px;font-variant-numeric:tabular-nums}.br-stats span{font-size:10px;color:var(--br-muted)}.br-section{padding:14px 18px;border-bottom:1px solid var(--br-line)}.br-section h3{font-size:12px;margin:0 0 10px}.br-section-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}.br-section-head h3{margin:0}.br-section-head button{padding:2px 8px}.br-graph{overflow:auto;background:var(--br-soft);border:1px solid var(--br-line);border-radius:9px;max-height:420px}.br-graph-canvas{position:relative}.br-graph svg{position:absolute;inset:0;overflow:visible}.br-graph path{fill:none;stroke:var(--br-muted);stroke-width:1.2}.br-graph text{font-size:10px;fill:var(--br-muted)}.br-node{position:absolute;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;width:154px;height:72px;text-align:left;gap:2px;box-shadow:0 2px 5px #00000006}.br-node[aria-pressed=true]{outline:2px solid var(--br-accent);outline-offset:2px}.br-node strong{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.br-node small{font-size:10px;color:var(--br-muted)}.br-legend{display:flex;gap:12px;flex-wrap:wrap;font-size:10px;color:var(--br-muted);margin-top:8px}.br-note{font-size:11px;color:var(--br-muted);margin:8px 0 0;overflow-wrap:anywhere} +.br-notice{margin:12px 18px;padding:10px 12px;border:1px solid var(--br-line);border-radius:8px;background:var(--br-soft);overflow-wrap:anywhere}.br-notice[role=alert]{color:var(--br-red)}.br-empty{padding:48px 24px;text-align:center}.br-empty h3{font-size:15px}.br-empty p{color:var(--br-muted);max-width:36em;margin:12px auto}.br-detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 16px;font-size:11px;margin:12px 0}.br-detail-grid span{color:var(--br-muted);display:block}.br-output{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--br-soft);border:1px solid var(--br-line);padding:12px;border-radius:8px;max-height:340px;overflow:auto;font:11px/1.65 ui-monospace,SFMono-Regular,monospace;margin:8px 0}.br-tabs{display:flex;gap:5px;margin:14px 0 10px}.br-tabs button[aria-pressed=true]{border-color:var(--br-accent);color:var(--br-accent)}.br-events{list-style:none;padding:0;margin:0;max-height:320px;overflow:auto}.br-events li{padding:8px 0;display:grid;grid-template-columns:64px 1fr;gap:8px;border-bottom:1px solid var(--br-line);font-size:11px}.br-events time{color:var(--br-muted);font-variant-numeric:tabular-nums}.br-events p{margin:0;overflow-wrap:anywhere}.br-events small{color:var(--br-muted)}.br-header-action{font:inherit;border:0;border-radius:6px;padding:5px 9px;background:transparent;color:inherit;cursor:pointer}.br-header-action:hover{background:#8882} +@container (max-width:330px){.br-stats{grid-template-columns:repeat(2,1fr)}.br-header,.br-section{padding-left:12px;padding-right:12px}} +@media(prefers-color-scheme:dark){.braid-panel{--br-bg:var(--dsw-alias-bg-base,#171b22);--br-fg:var(--dsw-alias-label-primary,#e0e6ee);--br-soft:var(--dsw-alias-bg-module-platform,#202630);--br-line:var(--dsw-alias-border-l2,#354050);--br-muted:var(--dsw-alias-label-secondary,#a0adbe);--br-accent:#80b6ff;--br-green:var(--dsw-alias-state-success-primary,#69d6a0);--br-red:var(--dsw-alias-state-error-primary,#ff919a)}.braid-panel .br-primary{color:#162336}} +[data-theme=dark] .braid-panel,.dark .braid-panel{--br-bg:var(--dsw-alias-bg-base,#171b22);--br-fg:var(--dsw-alias-label-primary,#e0e6ee);--br-soft:var(--dsw-alias-bg-module-platform,#202630);--br-line:var(--dsw-alias-border-l2,#354050);--br-muted:var(--dsw-alias-label-secondary,#a0adbe);--br-accent:#80b6ff;--br-green:var(--dsw-alias-state-success-primary,#69d6a0);--br-red:var(--dsw-alias-state-error-primary,#ff919a)} +`; + +export const toolStyles = ` +.br-tool-call{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary,inherit);border:1px solid var(--dsw-alias-border-l2,#8884);border-radius:9px;margin:8px 0;overflow:hidden} +.br-tool-call button{font:inherit;color:inherit;cursor:pointer}.br-tool-call button:focus-visible{outline:2px solid #639be6;outline-offset:-2px}.br-tool-call button:disabled{cursor:default;opacity:.6} +.br-tool-open{display:flex;width:100%;align-items:center;gap:10px;padding:12px;border:0;background:var(--dsw-alias-bg-module-platform,#8881);text-align:left}.br-tool-open:hover:not(:disabled){background:var(--dsw-alias-fill-l1,#8882)}.br-tool-open>span:last-child{margin-left:auto} +.br-tool-main{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 8px;min-width:0}.br-tool-target{overflow-wrap:anywhere;color:var(--dsw-alias-label-secondary,inherit)}.br-tool-summary{flex-basis:100%;font-size:11px}.br-tool-call[data-state=failed] .br-tool-summary,.br-tool-error{color:var(--dsw-alias-state-error-primary,#c34e59)} +.br-tool-actions{display:flex;gap:8px;padding:6px 12px}.br-tool-actions button{border:0;background:transparent;font-size:11px;text-decoration:underline;text-underline-offset:3px;padding:3px 0}.br-tool-details{padding:8px 12px;border-top:1px solid var(--dsw-alias-border-l2,#8884)}.br-tool-details pre{font:11px/1.6 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-height:220px;overflow:auto}.br-tool-error{padding:0 12px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px} +.br-tool-note{margin:4px 12px;color:var(--dsw-alias-label-secondary,inherit);font-size:10px;opacity:.8}.br-tool-changes{display:flex;flex-wrap:wrap;gap:5px;padding:8px 12px}.br-tool-changes span{padding:3px 6px;border-radius:4px;background:var(--dsw-alias-bg-module-platform,#8881);font-size:10px;overflow-wrap:anywhere}.br-tool-jobs{list-style:none;margin:0;padding:6px 12px;max-height:230px;overflow:auto}.br-tool-jobs button{display:flex;gap:12px;width:100%;padding:6px 0;border:0;background:transparent;text-align:left}.br-tool-jobs span{margin-left:auto} +`; diff --git a/integrations/dsh/cordis.patch.yml b/integrations/dsh/cordis.patch.yml new file mode 100644 index 0000000..d9d53df --- /dev/null +++ b/integrations/dsh/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: chrok-braid + name: "@chrok/dsh-braid" diff --git a/integrations/dsh/display.ts b/integrations/dsh/display.ts new file mode 100644 index 0000000..7517d7c --- /dev/null +++ b/integrations/dsh/display.ts @@ -0,0 +1,24 @@ +import type { JobSnapshot } from "./jobs.js"; + +/** Plain text works in both DSH's native job panel and human command results. */ +export function renderPanel(job: JobSnapshot): string { + const state = job.execution; + const lines = [`Braid ${job.handle} · ${job.status} · revision ${state.revision}`, job.goal, + `Paused: ${state.pausedExecutionIds.join(", ") || "none"}`, ""]; + for (const node of state.graph.nodes.slice(0, 80)) { + const result = state.nodes[node.id]; + const progress = result?.executionId ? job.progress[result.executionId] : undefined; + lines.push(`${result?.status === "running" ? "▶" : "·"} ${node.id} [${node.type}] ${result?.status ?? "pending"}` + + (progress ? ` · context ${progress.contextTokens}${progress.contextWindow ? `/${progress.contextWindow}` : ""} (${progress.contextSource}) · tools ${progress.toolCalls}` : "") + + (result?.startedAt ? ` · ${Math.max(0, (result.finishedAt ?? Date.now()) - result.startedAt)} ms` : "")); + for (const edge of state.graph.edges.filter(edge => edge.from === node.id)) + lines.push(` └─${edge.choice ? ` ${edge.choice}` : ""}${edge.feedback ? ` ↻ ${edge.feedback}` : ""} → ${edge.to}`); + if (result?.error) lines.push(` ${result.error.code}: ${result.error.message}`); + } + if (state.graph.nodes.length > 80) lines.push("… additional nodes available through braid_status"); + lines.push("", "Recent events:", ...job.events.slice(-12).map(event => + `#${event.sequence} ${event.type}${"nodeId" in event ? ` · ${event.nodeId}` : ""}${event.executionId ? ` · ${event.executionId}` : ""}`)); + lines.push("", `Usage: ${job.usage.totalTokens ?? 0} tokens (including cache)`, + `Results: braid_status({"jobId":"${job.handle}"})`, `/braid cancel ${job.handle}`); + return lines.join("\n"); +} diff --git a/integrations/dsh/index.ts b/integrations/dsh/index.ts new file mode 100644 index 0000000..a2dce26 --- /dev/null +++ b/integrations/dsh/index.ts @@ -0,0 +1,149 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import { createUserMessage, type UserMessage } from "@deepseek-ai/dsh-llm"; +import type {} from "@deepseek-ai/dsh-tools"; +import type {} from "@deepseek-ai/dsh-commands"; +import type {} from "@deepseek-ai/dsh-system-prompt"; +import type {} from "@deepseek-ai/dsh-jobs"; +import type { BraidInput } from "@chrok/braid"; +import { BraidJobs, type JobOptions, type JobSnapshot } from "./jobs.js"; +import { createBraidTools, graphReceipt } from "./tools.js"; +import { BRAID_USAGE_GUIDANCE } from "./schema.js"; +import { renderPanel } from "./display.js"; +import { BraidPanel } from "./panel.js"; + +declare module "@deepseek-ai/dsh-llm" { + interface MessageSourceMap { + braid: { kind: "braid"; reminderId: string; form: "notice"; summary: string }; + } +} +declare module "@deepseek-ai/dsh-jobs" { + interface JobKindMap { braid: "braid" } +} + +export const name = "braid"; +export const inject = ["tools", "llm", "commands", "systemPrompt", "jobs"]; + +export function apply(ctx: Context): void { + const observers = new Set<() => void>(); + const changed = () => { for (const listener of observers) listener(); }; + const sessions = new Map; queued: Set; release: () => Promise }>(); + ctx.inject(["typert"], ctx => { + new BraidPanel(ctx, id => [...sessions].find(([agent]) => agent.session.id === id)?.[1].jobs, + listener => { observers.add(listener); return () => { observers.delete(listener); }; }); + }); + const send = (agent: Agent, message: UserMessage) => { + if (message.source.kind !== "braid") return; + const state = sessions.get(agent); + if (!state || state.queued.has(message.source.reminderId)) return; + state.queued.add(message.source.reminderId); + try { agent.steer(message); } + catch { state.queued.delete(message.source.reminderId); } + }; + const session = (agent: Agent) => { + const existing = sessions.get(agent); + if (existing) return existing; + const pending = new Map(); + const jobs = new BraidJobs(ctx.llm, completion => { + const { jobId, handle, status, executionId, nodeId, paused, sequence } = completion; + const reminderId = JSON.stringify([jobId, executionId ?? "job", sequence ?? 0]); + const lookup = JSON.stringify({ jobId: handle, ...(executionId ? { executionId } : {}) }); + const summary = `Braid ${handle}${nodeId ? ` / ${nodeId}` : ""}: ${paused ? "paused" : status}`.slice(0, 120); + const message = createUserMessage({ + source: { kind: "braid", reminderId, form: "notice", summary }, + content: [{ type: "text", text: `[system-reminder] ${summary}. Retrieve results with braid_status(${lookup}) and continue the original task. Failed or cancelled jobs may contain successful partial results. ${paused ? "Outgoing scheduling was paused. Inspect CURRENT execution.revision and pausedExecutionIds before braid_update with resume or braid_resume. Deadlines continue; finalized jobs cannot resume." : "This reminder does not pause scheduling."} [/system-reminder]` }], + }); + pending.set(reminderId, message); send(agent, message); + }); + const value = { jobs, pending, queued: new Set(), release: () => jobs.dispose() }; + sessions.set(agent, value); + const off = jobs.subscribe(changed); + changed(); + // Agent-scoped effects unwind on clear, reload, disposal, or session replacement. + value.release = agent.ctx.effect(() => () => { + off(); pending.clear(); sessions.delete(agent); changed(); return jobs.dispose(); + }); + return value; + }; + const requireAgent = (agent: Agent | undefined): Agent => { + if (!agent) throw new Error("Braid requires an owning DSH agent"); + return agent; + }; + ctx.jobs.attachController("braid"); + for (const tool of createBraidTools(exec => session(requireAgent(exec.agent)).jobs, (args, exec) => { + const jobs = session(requireAgent(exec.agent)).jobs; + const agent = requireAgent(exec.agent); + const { options, ...input } = args; + let snapshot: JobSnapshot | undefined; + const nativeJobId = ctx.jobs.start({ + kind: "braid", label: args.goal.slice(0, 200), owner: agent.session.id, + run(native) { + const { provider, model } = agent.options; + snapshot = jobs.start(input as BraidInput, (options ?? {}) as JobOptions, { + cwd: agent.session.header.cwd ?? process.cwd(), + ...(provider && model ? { model: `${provider}/${model}` } : {}), + }); + const handle = snapshot.handle; + native.append(renderPanel(snapshot) + "\n", { channel: "log" }); + let previous = "", timer: ReturnType | undefined; + const refresh = () => { + timer = undefined; + const job = jobs.get(handle); + const panel = renderPanel(job); + if (panel !== previous) { previous = panel; native.append("\n" + panel + "\n", { channel: "log" }); } + native.updateProgress(`${handle} · ${job.execution.status} · ${Object.keys(job.execution.executions).length} executions`); + }; + const unsubscribe = jobs.subscribe(() => { timer ??= setTimeout(refresh, 250); }); + return { + cancel: () => { jobs.cancel(handle); }, + done: jobs.wait(handle).then(() => { + clearTimeout(timer); unsubscribe(); refresh(); + const job = jobs.get(handle); + return { status: job.status === "cancelled" ? "killed" as const : job.status === "completed" ? "completed" as const : "failed" as const, + detail: `Braid ${handle}; retrieve with braid_status`, result: JSON.stringify({ jobId: handle, status: job.status }) }; + }), + }; + }, + }); + return { jobId: snapshot!.handle, canonicalJobId: snapshot!.jobId, nativeJobId, status: snapshot!.status, + graph: graphReceipt(snapshot!), message: "Running in background. Open Braid in the session header for the live graph and execution details, or use the Jobs panel / /braid. Completion reminders wake an idle agent or steer at the next step. Do not poll repeatedly." }; + })) ctx.tools.register(tool); + + ctx.systemPrompt.section({ name: "braid", order: 80, text: BRAID_USAGE_GUIDANCE, interpolate: false }); + ctx.commands.register({ name: "braid", description: "Show Braid flow/progress or cancel a job; live progress also appears in the Jobs panel.", input: { hint: "[jobId] | cancel | list" }, + handler({ agent, rawInput, signal }) { + signal.throwIfAborted(); + try { + const jobs = session(agent).jobs; + const parts = rawInput.trim().split(/\s+/).filter(Boolean); + if (parts[0] === "cancel" && parts.length === 2) + return { kind: "success", text: jobs.cancel(parts[1]!) ? "Cancellation requested." : "Job already finished." }; + if (parts.length > 1 || parts[0] === "cancel") return { kind: "error", text: "Usage: /braid [jobId] | cancel | list" }; + if (parts[0] === "list") return { kind: "success", text: JSON.stringify(jobs.list(), null, 2) }; + const id = parts[0] ?? jobs.list()[0]?.handle; + return { kind: "success", text: id ? renderPanel(jobs.get(id)) : "No Braid jobs in this session." }; + } catch (error) { return { kind: "error", text: error instanceof Error ? error.message : String(error) }; } + }, + }); + ctx.on("agent/inbox/claimed", ({ agent, message }) => { + if (message.source.kind === "braid") { + sessions.get(agent)?.pending.delete(message.source.reminderId); + sessions.get(agent)?.queued.delete(message.source.reminderId); + } + }); + ctx.on("agent/inbox/discarded", ({ agent, message }) => { + if (message.source.kind === "braid") sessions.get(agent)?.queued.delete(message.source.reminderId); + }); + ctx.on("agent/status", ({ agent, status }) => { + if (status === "idle") for (const message of sessions.get(agent)?.pending.values() ?? []) send(agent, message); + }); + ctx.on("agent/disposed", ({ agent }) => { + const value = sessions.get(agent); + value?.pending.clear(); sessions.delete(agent); changed(); void value?.release(); + }); + ctx.effect(() => async () => { + const active = [...sessions.values()]; sessions.clear(); + for (const value of active) value.pending.clear(); + await Promise.all(active.map(value => value.release())); + }); +} diff --git a/integrations/dsh/jobs.ts b/integrations/dsh/jobs.ts new file mode 100644 index 0000000..78e18be --- /dev/null +++ b/integrations/dsh/jobs.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import { startBraid, validateGraph, type BraidInput, type BraidRun, type BraidResult, type BraidSnapshot, type GraphUpdate, type ExecutionEvent, type NodeResult } from "@chrok/braid"; +import type { TokenUsage } from "@deepseek-ai/dsh-llm"; +import { createDshRunner, sumUsage, validateBudgets, type NodeProgress, type DshLlm } from "./runner.js"; + +export interface JobOptions { + maxConcurrency?: number; + maxExecutions?: number; + nodeTimeoutMs?: number; + graphTimeoutMs?: number; + maxToolRounds?: number; + maxToolCalls?: number; +} + +export interface JobSnapshot { + jobId: string; + handle: string; + goal: string; + status: "running" | "completed" | "failed" | "cancelled"; + createdAt: number; + execution: BraidSnapshot; + result?: BraidResult; + error?: string; + usage: TokenUsage; + progress: Record; + events: ExecutionEvent[]; +} + +interface Job { + jobId: string; + handle: string; + goal: string; + status: JobSnapshot["status"]; + createdAt: number; + run: BraidRun; + done: Promise; + result?: BraidResult; + error?: string; + reports: TokenUsage[]; + progress: Record; + events: ExecutionEvent[]; +} + +export interface Completion { + jobId: string; + handle: string; + status: string; + executionId?: string; + nodeId?: string; + iteration?: number; + paused?: boolean; + sequence?: number; +} + +/** One manager per live DSH agent; foreground cancellation never owns these runs. */ +export class BraidJobs { + private jobs = new Map(); + private handles = new Map(); + private listeners = new Set<() => void>(); + private disposed = false; + private nextHandle = 1; + + constructor(private llm: DshLlm, private notify: (completion: Completion) => void = () => {}) {} + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + private changed(): void { + for (const listener of this.listeners) { try { listener(); } catch { /* UI isolation. */ } } + } + + start(input: BraidInput, options: JobOptions, context: { cwd: string; model?: string }): JobSnapshot { + if (this.disposed) throw new Error("Braid session has closed"); + validateGraph(input); + validateBudgets({ maxToolRounds: options.maxToolRounds, maxToolCalls: options.maxToolCalls }); + const jobId = randomUUID(), handle = `job-${this.nextHandle}`; + const reports: TokenUsage[] = []; + const events: ExecutionEvent[] = []; + const progress: Record = Object.create(null); + const invoke = createDshRunner(this.llm, { cwd: context.cwd, + ...(options.maxToolRounds === undefined ? {} : { maxToolRounds: options.maxToolRounds }), + ...(options.maxToolCalls === undefined ? {} : { maxToolCalls: options.maxToolCalls }), + onUsage: usage => { reports.push(usage); }, + onProgress: value => { progress[value.executionId] = value; this.changed(); }, + }); + // Core options and graph validation happen synchronously before publishing a handle. + const run = startBraid(structuredClone(input), { + cwd: context.cwd, ...options, + nodeTimeoutMs: options.nodeTimeoutMs ?? Infinity, graphTimeoutMs: options.graphTimeoutMs ?? Infinity, + ...(context.model ? { defaultModel: context.model } : {}), + runner: async request => { await nextTurn(); return invoke(request); }, + onEvent: event => { + events.push(structuredClone(event)); + if (events.length > 80) events.shift(); + this.changed(); + this.onEvent(jobId, handle, event); + }, + }); + const job: Job = { jobId, handle, goal: input.goal, status: "running", createdAt: Date.now(), run, reports, progress, events, done: Promise.resolve() }; + this.nextHandle++; + this.jobs.set(jobId, job); this.handles.set(handle, jobId); + job.done = (async () => { + try { + job.result = await run.result; + job.status = job.result.error?.code === "CANCELLED" ? "cancelled" : job.result.status; + } catch (error) { + job.error = error instanceof Error ? error.message : String(error); + job.status = "failed"; + } + this.changed(); + this.remind({ jobId, handle, status: job.status }); + })(); + this.changed(); + return this.get(handle); + } + + private onEvent(jobId: string, handle: string, event: ExecutionEvent): void { + if (!["execution_paused", "node_completed", "node_failed"].includes(event.type) || !event.executionId) return; + const state = this.jobs.get(jobId)?.run.snapshot(); + const node = state?.executions[event.executionId]; + if (!node) return; + const paused = event.type === "execution_paused"; + if (!paused && (!node.node.notifyOnCompletion || (node.node.pauseAfter && !state?.error))) return; + this.remind({ jobId, handle, status: node.status, executionId: node.executionId, nodeId: node.id, + ...(node.iteration === undefined ? {} : { iteration: node.iteration }), paused, sequence: event.sequence }); + } + + private remind(completion: Completion): void { + if (this.disposed) return; + try { this.notify(completion); } catch { /* Notification failures cannot fail jobs. */ } + } + + private lookup(id: string): Job { + const job = this.jobs.get(this.handles.get(id) ?? id); + if (!job) throw new Error(`Unknown Braid job: ${id}. Use an exact session handle or UUID. Available jobs: ${JSON.stringify(this.list())}`); + return job; + } + + get(id: string): JobSnapshot { + const { run, done: _done, reports, ...job } = this.lookup(id); + return structuredClone({ ...job, execution: run.snapshot(), usage: sumUsage(reports) }); + } + + getNode(id: string, nodeId?: string, executionId?: string): NodeResult { + const state = this.lookup(id).run.snapshot(); + const selected = executionId ? state.executions : state.nodes; + const key = executionId ?? nodeId; + const node = key && Object.hasOwn(selected, key) ? selected[key] : undefined; + if (!node || (nodeId && node.id !== nodeId)) throw new Error("Unknown Braid node or execution; inspect braid_status with only jobId"); + return structuredClone(node); + } + + list() { + return [...this.jobs.values()].reverse().map(({ jobId, handle, goal, status, createdAt }) => ({ jobId, handle, goal, status, createdAt })); + } + + update(id: string, patch: GraphUpdate): JobSnapshot { + this.lookup(id).run.update(patch); this.changed(); return this.get(id); + } + + resume(id: string, executionIds: readonly string[], expectedRevision: number): JobSnapshot { + this.lookup(id).run.resume(executionIds, expectedRevision); this.changed(); return this.get(id); + } + + cancel(id: string): boolean { + const job = this.lookup(id); + if (job.status !== "running") return false; + job.run.cancel(); return true; + } + + async wait(id: string): Promise { await this.lookup(id).done; } + + async dispose(): Promise { + this.disposed = true; + this.listeners.clear(); + for (const job of this.jobs.values()) job.run.cancel(); + await Promise.all([...this.jobs.values()].map(job => job.done)); + } +} diff --git a/integrations/dsh/package.json b/integrations/dsh/package.json new file mode 100644 index 0000000..ed05de0 --- /dev/null +++ b/integrations/dsh/package.json @@ -0,0 +1,119 @@ +{ + "name": "@chrok/dsh-braid", + "version": "0.3.0", + "license": "MIT", + "description": "DeepSeek Harness plugin for Braid: background agent graphs, bounded loops, live updates, and execution progress", + "type": "module", + "dependencies": { + "@chrok/braid": "0.3.0", + "@sinclair/typebox": "^0.34.41" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-commands": "0.2.0-rc.2", + "@deepseek-ai/dsh-jobs": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.2" + }, + "devDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-chat": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2", + "@deepseek-ai/dsh-client-ui-tool": "0.2.0-rc.2", + "@deepseek-ai/dsh-commands": "0.2.0-rc.2", + "@deepseek-ai/dsh-jobs": "0.2.0-rc.2", + "@deepseek-ai/dsh-jobs-local": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2", + "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.2", + "@types/jsdom": "21.1.7", + "@types/node": "^22.0.0", + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "esbuild": "0.25.12", + "jsdom": "26.1.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + }, + "scripts": { + "check": "tsc --noEmit && tsc -p tsconfig.client.json --noEmit", + "test": "node build-client.mjs && tsx --tsconfig tsconfig.json --test test/*.test.ts test/*.test.mjs test/client/*.test.tsx", + "build": "node ../../scripts/build.mjs dsh", + "prepack": "npm run build" + }, + "engines": { + "node": ">=22.19.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Epsirom/braid.git", + "directory": "integrations/dsh" + }, + "homepage": "https://github.com/Epsirom/braid/tree/main/integrations/dsh#readme", + "bugs": { + "url": "https://github.com/Epsirom/braid/issues" + }, + "keywords": [ + "dsh-plugin", + "llm", + "ai-agents", + "agents", + "agent-orchestration", + "graph", + "bounded-loops", + "git-worktree", + "typescript" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "cordis.patch.yml" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json", + "./client": { + "types": "./dist/client/index.d.ts", + "default": "./dist/client.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-tool" + ] + } + } +} diff --git a/integrations/dsh/panel-contract.ts b/integrations/dsh/panel-contract.ts new file mode 100644 index 0000000..c38e9ed --- /dev/null +++ b/integrations/dsh/panel-contract.ts @@ -0,0 +1,37 @@ +import type { InvocationDescriptor, RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { ControlRequest, DetailRequest, PanelDetail, PanelFrame, PanelRequest } from "./panel-types.js"; + +/** One descriptor table shared by Host registration and Client Remote mounting. */ +export const PANEL_PACKAGE = "@chrok/dsh-braid"; +function parseRequest(value: unknown, method: string): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Braid panel request"); + const r = value as Record; + const id = (key: string) => typeof r[key] === "string" && (r[key] as string).length > 0 && (r[key] as string).length <= 1024; + if (!id("sessionId") || (r.jobId !== undefined && !id("jobId"))) throw new Error("Invalid session or job ID"); + if (r.createdBefore !== undefined && (!Number.isSafeInteger(r.createdBefore) || Number(r.createdBefore) < 0)) throw new Error("Invalid call timestamp"); + if (method !== "watch" && !id("jobId")) throw new Error("A job ID is required"); + if (method === "control" && ( + !["cancel", "resume"].includes(String(r.action)) || !Number.isSafeInteger(r.revision) || Number(r.revision) < 0 || + !Array.isArray(r.executionIds) || r.executionIds.length > 10000 || r.executionIds.some(v => typeof v !== "string" || !v || v.length > 1024) + )) throw new Error("Invalid Braid control request"); + if (method === "detail" && (!id("executionId") || !Number.isSafeInteger(r.offset) || Number(r.offset) < 0)) throw new Error("Invalid execution or offset"); + return value; +} +export const panelDescriptors: InvocationDescriptor[] = ["watch", "control", "detail"].map(method => ({ + id: `${PANEL_PACKAGE}#braidPanel/${method}`, service: "braidPanel", namespace: "braidPanel", method, + invocation: { kind: "direct" }, + parameters: [{ name: "request", wire: "request", source: "json", codec: { + mode: "strict", typeSymbol: `${PANEL_PACKAGE}/${method}Request`, create: () => ({ parse: value => parseRequest(value, method) }), + } }], + result: { mode: "src-json" }, + ...(method === "watch" ? { mode: "stream" as const, cancellation: { parameter: "signal" as const } } : {}), +})); +export interface PanelRemote { + watch(request: PanelRequest, signal?: AbortSignal): AsyncIterable; + control(request: ControlRequest): Promise>; + detail(request: DetailRequest): Promise>; +} +declare module "@deepseek-ai/dsh-typert-protocol" { + interface TypertRemoteNamespaceMap { braidPanel: PanelRemote } + interface RemoteErrorDetailsMap { "braid/panel": { sessionId: string } } +} diff --git a/integrations/dsh/panel-types.ts b/integrations/dsh/panel-types.ts new file mode 100644 index 0000000..f8fa391 --- /dev/null +++ b/integrations/dsh/panel-types.ts @@ -0,0 +1,30 @@ +/** Browser-safe, bounded projections. Prompts and provider messages never enter the panel stream. */ +export interface PanelRequest { sessionId: string; jobId?: string; createdBefore?: number } +export interface ControlRequest extends PanelRequest { jobId: string; action: "cancel" | "resume"; revision: number; executionIds: string[] } +export interface DetailRequest extends PanelRequest { jobId: string; executionId: string; offset: number } +export interface PanelRow { jobId: string; handle: string; goal: string; status: string; createdAt: number } +export interface PanelExecution { + id: string; executionId: string; status: string; type: string; revision: number; + iteration?: number; loopId?: string; latencyMs?: number; + progress?: { contextTokens: number; contextWindow?: number; contextSource: string; toolCalls: number; toolRounds: number; phase: string }; +} +export interface PanelJob extends PanelRow { + revision: number; phase: string; error?: string; + nodes: { id: string; type: string; status: string }[]; + edges: { from: string; to: string; choice?: string; feedback?: string; executionId?: string }[]; + loops: { id: string; entry: string; maxIterations: number }[]; + executions: PanelExecution[]; + pausedExecutionIds: string[]; + usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }; + events: { sequence: number; timestamp: number; type: string; executionId?: string; nodeId?: string; message: string }[]; +} +export interface PanelFrame { rows: PanelRow[]; job: PanelJob | null } +export interface PanelDetail { + executionId: string; status: string; output: string; offset: number; next: number; total: number; + decision?: string; error?: string; workspace?: string; +} +export interface PanelApi { + watch(request: PanelRequest, signal: AbortSignal): AsyncIterable; + control(request: ControlRequest): Promise; + detail(request: DetailRequest): Promise; +} diff --git a/integrations/dsh/panel.ts b/integrations/dsh/panel.ts new file mode 100644 index 0000000..ce73687 --- /dev/null +++ b/integrations/dsh/panel.ts @@ -0,0 +1,103 @@ +import type { Context } from "@deepseek-ai/cordis"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import type {} from "@deepseek-ai/dsh-typert-registry"; +import type { BraidJobs, JobSnapshot } from "./jobs.js"; +import { PANEL_PACKAGE, panelDescriptors } from "./panel-contract.js"; +import type { ControlRequest, DetailRequest, PanelDetail, PanelFrame, PanelJob, PanelRequest } from "./panel-types.js"; + +const clip = (text: string, limit = 2000) => text.length > limit ? text.slice(0, limit) + "…" : text; +export function panelJob(job: JobSnapshot): PanelJob { + const state = job.execution; + return { + jobId: job.jobId, handle: job.handle, goal: clip(job.goal), status: job.status, createdAt: job.createdAt, + revision: state.revision, phase: state.status, + ...(job.error || state.error ? { error: clip(job.error ?? state.error!.message) } : {}), + nodes: state.graph.nodes.map(node => ({ id: node.id, type: node.type, status: state.nodes[node.id]?.status ?? "pending" })), + edges: state.graph.edges.map(edge => ({ ...edge })), loops: [...state.graph.loops ?? []], + executions: Object.values(state.executions).map(e => ({ + id: e.id, executionId: e.executionId, status: e.status, type: e.node.type, revision: e.revision, + ...(e.iteration === undefined ? {} : { iteration: e.iteration }), + ...(e.loopId === undefined ? {} : { loopId: e.loopId }), + ...(e.latencyMs === undefined ? {} : { latencyMs: e.latencyMs }), + ...(job.progress[e.executionId] ? { progress: job.progress[e.executionId]! } : {}), + })), + pausedExecutionIds: [...state.pausedExecutionIds], + usage: { inputTokens: job.usage.inputTokens ?? 0, outputTokens: job.usage.outputTokens ?? 0, + cacheReadTokens: job.usage.cacheReadTokens ?? 0, cacheWriteTokens: job.usage.cacheWriteTokens ?? 0 }, + events: job.events.map(e => ({ sequence: e.sequence, timestamp: e.timestamp, type: e.type, + ...(e.executionId ? { executionId: e.executionId } : {}), ...("nodeId" in e ? { nodeId: e.nodeId } : {}), + message: clip("error" in e ? e.error.message : e.type === "handoff" ? `${e.from} → ${e.to}` : e.type === "edge_created" ? `${e.from} → ${e.to}` : e.type.replaceAll("_", " "), 500), + })), + }; +} + +/** Session managers are borrowed; viewing a panel never creates an Agent or starts model work. */ +export class BraidPanel extends TypertRemoteService { + constructor(ctx: Context, private readonly find: (sessionId: string) => BraidJobs | undefined, + private readonly subscribe: (listener: () => void) => () => void) { + super(ctx, "braidPanel"); + ctx.typert.register({ package: PANEL_PACKAGE, face: "host", schemas: [], model: { services: [], events: [], objects: [] }, invocations: panelDescriptors }); + } + snapshot(request: PanelRequest): PanelFrame { + const jobs = this.find(request.sessionId); + const rows = jobs?.list().map(row => ({ ...row, goal: clip(row.goal) })) ?? []; + // Old tool cards can carry short handles. Fence them by the call's timestamp + // so a Host/plugin restart cannot redirect an old card to a reused handle. + const selected = request.jobId ? rows.find(row => (row.jobId === request.jobId || row.handle === request.jobId) + && (request.createdBefore === undefined || row.createdAt <= request.createdBefore)) : rows[0]; + if (request.jobId && !selected) throw new Error("Braid job is no longer available in this session"); + const id = selected?.jobId; + return { rows, job: id && jobs ? panelJob(jobs.get(id)) : null }; + } + @Remote({ mode: "stream" }) + async *watch(request: PanelRequest, signal: AbortSignal): AsyncIterable { + let dirty = true, wake: (() => void) | undefined; + const changed = () => { dirty = true; wake?.(); }; + const off = this.subscribe(changed); + signal.addEventListener("abort", changed, { once: true }); + try { + while (!signal.aborted) { + if (!dirty) await new Promise(resolve => { wake = resolve; }); + wake = undefined; + if (signal.aborted) break; + dirty = false; + try { yield this.snapshot(request); } + catch (error) { throw this.failure(request, error); } + // Coalesce token bursts; cancellation also wakes this delay. + await new Promise(resolve => { + const done = () => { clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); }; + const timer = setTimeout(done, 150); + signal.addEventListener("abort", done, { once: true }); + if (signal.aborted) done(); + }); + } + } finally { off(); signal.removeEventListener("abort", changed); } + } + @Remote + control(request: ControlRequest): PanelFrame { + try { + this.snapshot(request); + const jobs = this.find(request.sessionId)!; + if (request.action === "resume") jobs.resume(request.jobId, request.executionIds, request.revision); + else jobs.cancel(request.jobId); + return this.snapshot(request); + } catch (error) { throw this.failure(request, error); } + } + @Remote + detail(request: DetailRequest): PanelDetail { + try { + this.snapshot(request); + const node = this.find(request.sessionId)!.getNode(request.jobId, undefined, request.executionId); + const output = node.output ?? ""; + const offset = Math.min(request.offset, output.length), next = Math.min(offset + 32768, output.length); + return { executionId: request.executionId, status: node.status, output: output.slice(offset, next), offset, next, total: output.length, + ...(node.decision === undefined ? {} : { decision: clip(node.decision) }), + ...(node.error ? { error: clip(node.error.message) } : {}), + ...(node.workspace ? { workspace: clip(JSON.stringify(node.workspace, null, 2), 8000) } : {}), + }; + } catch (error) { throw this.failure(request, error); } + } + private failure(request: PanelRequest, error: unknown) { + return new RemoteError("braid/panel", error instanceof Error ? error.message : String(error), { sessionId: request.sessionId }); + } +} diff --git a/integrations/dsh/runner.ts b/integrations/dsh/runner.ts new file mode 100644 index 0000000..582579c --- /dev/null +++ b/integrations/dsh/runner.ts @@ -0,0 +1,188 @@ +import { resolve } from "node:path"; +import { + BlockAssembler, createToolResultMessage, + type LlmRuntime, type RequestMessage, type TokenUsage, +} from "@deepseek-ai/dsh-llm"; +import { + formatBudgetReminder, gitToolDefinition, finishMergeToolDefinition, + mergeInstructions, parseGitToolArguments, parseFinishMergeArguments, + type ModelRunner, +} from "@chrok/braid"; +import { createWorkerTools } from "./worker-tools.js"; + +export interface NodeProgress { + nodeId: string; + executionId: string; + contextTokens: number; + contextWindow?: number; + contextSource: "reported" | "estimate"; + toolCalls: number; + toolRounds: number; + phase: "model" | "tool"; +} + +export interface RunnerOptions { + cwd: string; + maxToolRounds?: number; + maxToolCalls?: number; + onUsage?: (usage: TokenUsage) => void; + onProgress?: (progress: NodeProgress) => void; +} + +export function validateBudgets(options: { maxToolRounds?: number | undefined; maxToolCalls?: number | undefined }): void { + for (const [key, value] of Object.entries(options)) { + if (value !== undefined && value !== Infinity && (!Number.isSafeInteger(value) || value < 1)) + throw new Error(`${key} must be a positive safe integer or Infinity`); + } +} + +/** Fresh, private model contexts. DSH owns provider routing and credentials. */ +export type DshLlm = Pick & Partial>; + +export function createDshRunner(llm: DshLlm, options: RunnerOptions): ModelRunner { + validateBudgets({ maxToolRounds: options.maxToolRounds, maxToolCalls: options.maxToolCalls }); + return async request => { + const route = request.model; + const slash = route?.indexOf("/") ?? -1; + if (!route || slash < 1 || slash === route.length - 1) + throw new Error("Choose a DSH provider/model, or set node.model to an exact provider/model-id"); + const provider = route.slice(0, slash), model = route.slice(slash + 1); + const modelInfo = await llm.resolveModelInfo?.(provider, model, request.signal); + const contextWindow = modelInfo?.context?.contextWindow; + request.signal.throwIfAborted(); + const workspace = request.workspace ?? { + nodeId: request.node.id, mode: "read-only" as const, + workingDirectory: resolve(options.cwd), state: "ready" as const, + }; + const files = await createWorkerTools(request, workspace); + const tools = files.map(({ name, description, parameters }) => ({ name, description, parameters: JSON.parse(JSON.stringify(parameters)) as Record })); + const integrating = request.node.type === "merge" || request.node.type === "integrate"; + if (request.git) tools.push(gitToolDefinition(integrating)); + if (request.merge) tools.push(finishMergeToolDefinition(request.merge.sources.map(source => source.executionId!))); + if (request.node.type === "decision") tools.push({ + name: "decide", description: "Select exactly one declared choice before answering.", + parameters: { type: "object", properties: { choice: { type: "string", enum: request.node.choices } }, required: ["choice"], additionalProperties: false }, + }); + const messages: RequestMessage[] = [{ role: "user", content: [{ type: "text", text: JSON.stringify({ + goal: request.goal, nodeId: request.node.id, execution: request.execution, + prompt: request.node.prompt, predecessors: request.predecessors, workspace, + ...(request.merge ? { mergeSources: request.merge.sources, sourceCheckoutStatus: request.merge.sourceStatus } : {}), + }) }] }]; + const system = "You are an isolated Braid worker. Complete only the assigned node. " + + "Use read, ls, grep, and find to inspect files. Only writable Git workspaces have write, edit, and shell tools. " + + "Use your assigned workingDirectory. Never write outside it; integrate alone may edit sourceRoot. " + + "Shell tools have host permissions, not a sandbox. Do not change shared Git refs, branches, configuration, hooks, or worktree registrations. " + + "Do not launch recursive agents, daemons, or background servers. Use execution-specific temporary resources. " + + "Checkpoints exclude ignored new dependencies, caches, and build outputs. Run tests for your changes. " + + "Parent transcript, skills, extension tools, and MCP tools are not inherited. " + + mergeInstructions(request) + + (request.node.type === "decision" ? "Call decide exactly once, then answer. " : "") + + "Return a nonempty natural-language result."; + let toolRounds = 0, toolCalls = 0, decided = false; + let contextTokens = 0; + let contextSource: NodeProgress["contextSource"] = "estimate"; + const reports: TokenUsage[] = []; + const output: string[] = []; + const progress = (phase: NodeProgress["phase"]) => { + try { options.onProgress?.({ nodeId: request.node.id, executionId: request.execution.executionId!, contextTokens, ...(contextWindow === undefined ? {} : { contextWindow }), contextSource, toolCalls, toolRounds, phase }); } + catch { /* Observers cannot fail a worker. */ } + }; + while (true) { + request.signal.throwIfAborted(); + const budgets = [ + ...(options.maxToolRounds === undefined ? [] : [`Tool round budget: ${toolRounds}/${options.maxToolRounds} used.`]), + ...(options.maxToolCalls === undefined ? [] : [`Tool call budget: ${toolCalls}/${options.maxToolCalls} used; rejected requests count.`]), + "Reserve budget for decide or finish_merge. At zero remaining, return your final answer without further tools.", + ]; + const activeTools = tools.filter(tool => !decided || tool.name !== "decide"); + const prompt = system + formatBudgetReminder(request, budgets); + contextTokens = Math.ceil(JSON.stringify({ messages, system: prompt, tools: activeTools }).length / 4); + contextSource = "estimate"; + progress("model"); + const assembler = new BlockAssembler(); + let finished = false; + try { + for await (const chunk of llm.stream({ provider, model, messages, system: prompt, tools: activeTools, signal: request.signal })) { + request.signal.throwIfAborted(); + assembler.push(chunk); + if (chunk.type === "finish") finished = true; + } + } finally { + if (assembler.usage) { + const usage = structuredClone(assembler.usage); + reports.push(usage); + options.onUsage?.(usage); + contextTokens = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); + contextSource = "reported"; + progress("model"); + } + } + request.signal.throwIfAborted(); + const finish = assembler.finish; + if (!finished) throw new Error("DSH model stream ended without a finish event"); + if (finish.kind !== "stop" && finish.kind !== "tool-calls") + throw new Error("failure" in finish ? finish.failure.message : `DSH model ended with ${finish.kind}`); + const response = assembler.message({ provider, model, ...(assembler.replayState ? { replayState: assembler.replayState } : {}) }); + output.push(...response.content.flatMap(block => block.type === "text" ? [block.text] : [])); + const calls = response.content.filter(block => block.type === "tool-call"); + if (!calls.length) { + if (finish.kind === "tool-calls") throw new Error("DSH requested tool use without tool calls"); + break; + } + if (finish.kind !== "tool-calls") throw new Error("DSH returned tool calls with an invalid finish reason"); + toolRounds++; + toolCalls += calls.length; + progress("tool"); + if (toolRounds > (options.maxToolRounds ?? Infinity) || toolCalls > (options.maxToolCalls ?? Infinity)) + throw new Error("Braid node exceeded its tool budget"); + messages.push(response); + for (const call of calls) { + request.signal.throwIfAborted(); + let text: string, isError = false; + try { + const args: unknown = JSON.parse(call.arguments); + if (call.name === "decide" && request.decide) { + if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).length !== 1 || !("choice" in args) || typeof args.choice !== "string") + throw new Error("decide requires exactly one string argument: choice"); + request.decide(args.choice); + decided = true; + text = JSON.stringify({ choice: args.choice }); + } else if (call.name === "git" && request.git) { + const parsed = parseGitToolArguments(args, integrating); + const result = await request.git(parsed.args, parsed.input); + text = JSON.stringify(result); isError = result.exitCode !== 0; + } else if (call.name === "finish_merge" && request.merge) { + await request.merge.finish(parseFinishMergeArguments(args, request.merge.sources.map(source => source.executionId!))); + text = "Merge dispositions recorded. Return your final answer."; + } else { + const tool = files.find(tool => tool.name === call.name); + if (!tool) throw new Error(`Unavailable tool '${call.name}'. Available: ${activeTools.map(tool => tool.name).join(", ")}`); + const invoke = () => tool.execute(args); + text = tool.writes && request.withWorkspaceWrite ? await request.withWorkspaceWrite(invoke) : await invoke(); + } + } catch (error) { + request.signal.throwIfAborted(); + // Invalid or duplicate decisions are terminal, matching core's exactly-once contract. + if (call.name === "decide" && request.decide) throw error; + isError = true; + text = error instanceof Error ? error.message : String(error); + } + messages.push(createToolResultMessage({ callId: call.id, content: [{ type: "text", text }], isError })); + } + } + const text = output.filter(part => part.trim()).join("\n\n"); + if (!text.trim()) throw new Error("DSH model returned no textual output"); + const usage = sumUsage(reports); + return { output: text, model: route, usage: { + inputTokens: usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0), outputTokens: usage.outputTokens, + } }; + }; +} + +export function sumUsage(reports: readonly TokenUsage[]): TokenUsage { + const total: TokenUsage = { inputTokens: 0, outputTokens: 0 }; + for (const report of reports) for (const key of ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens"] as const) + if (report[key] !== undefined) total[key] = (total[key] ?? 0) + report[key]; + total.totalTokens = total.inputTokens + total.outputTokens + (total.cacheReadTokens ?? 0) + (total.cacheWriteTokens ?? 0); + return total; +} diff --git a/integrations/dsh/schema.ts b/integrations/dsh/schema.ts new file mode 100644 index 0000000..5ff9b6f --- /dev/null +++ b/integrations/dsh/schema.ts @@ -0,0 +1,103 @@ +import { Type } from "@sinclair/typebox"; +const StringEnum = (values: string[], options = {}) => Type.Union(values.map(value => Type.Literal(value)), options); +const text = (description?: string) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) }); +const prompt = () => Type.Union([ + text("Instructions for this node; it does not receive the parent conversation."), + Type.Object({ + template: text("Exact key in promptTemplates."), + variables: Type.Record(Type.String(), Type.String(), { + description: "Exactly the template's placeholder names with string values; use {} if there are no placeholders. No missing or extra keys.", + }), + }, { additionalProperties: false }), +], { description: "Required for execute/decision; optional for merge/integrate. A non-blank instruction string or a reference to a declared prompt template." }); +const timeout = (scope: "node" | "graph") => + Type.Optional( + Type.Number({ + exclusiveMinimum: 0, + maximum: 2_147_483_647, + description: scope === "graph" + ? "Total wall-clock timeout in milliseconds, including queueing and pauses. Updates/resume do not reset it; reserve time for final integration. Omit for no time limit." + : "Timeout in milliseconds per node execution; omit for no time limit.", + }), + ); +const toolBudget = (unit: string) => + Type.Optional( + Type.Integer({ + minimum: 1, + maximum: Number.MAX_SAFE_INTEGER, + description: `Maximum tool ${unit} per node, including decide and rejected requests; omit for no limit`, + }), + ); + +const commonNodeParameters = { + id: text("Unique node definition ID; edges refer to this exact ID. This is not an executionId."), + model: Type.Optional(text("Model override as provider/model-id; omit to use the parent model.")), + notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Send an execution completion reminder; default false. Does not pause scheduling." })), + pauseAfter: Type.Optional(Type.Boolean({ description: "Hold this execution's outgoing dependencies until braid_resume or an atomic braid_update with resume. Sends a pause reminder." })), + requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })), +}; +const workspace = Type.Optional(StringEnum(["read-only", "worktree"], { + description: "Only for execute/decision; omit for merge/integrate. Defaults to worktree in Git. Both modes use fresh predecessor snapshots; read-only disables writes and shell tools. Outside Git, all workers are read-only.", +})); +// Keep all fields visible in one object. Kimi sessions using object-variant +// unions repeatedly emitted bare merge nodes instead of intended executions. +// Core validates the conditional requirements before starting or changing a job. +export const nodeParameters = Type.Object({ + id: commonNodeParameters.id, + type: StringEnum(["execute", "decision", "merge", "integrate"], { + description: "execute: analyze/implement; decision: choose a route; merge: combine predecessor checkpoints in an isolated worktree; integrate: apply predecessor changes to the invoking checkout.", + }), + prompt: Type.Optional(prompt()), + choices: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Required only for decision; omit for every other type. Distinct routing labels used by decide and outgoing choice edges." })), + workspace, + model: commonNodeParameters.model, + notifyOnCompletion: commonNodeParameters.notifyOnCompletion, + pauseAfter: commonNodeParameters.pauseAfter, + requireSuccess: commonNodeParameters.requireSuccess, +}, { additionalProperties: false, description: "execute/decision REQUIRE prompt; decision also REQUIRES choices. merge/integrate may omit prompt but MUST omit choices and workspace. Omit unused optional fields. All types accept model, notifyOnCompletion, pauseAfter, and requireSuccess." }); +const edgeFields = { + from: text("Source node ID."), + to: text("Target node ID."), + choice: Type.Optional(text("Only for a decision source: one exact declared choice. Omit for an unconditional dependency, including error recovery.")), + feedback: Type.Optional(text("Loop ID, only on the single back edge from its decision to its entry. Requires choice and a matching loops definition; not a boolean. Cannot be combined with executionId.")), +}; +const edgeParameters = Type.Object(edgeFields, { additionalProperties: false }); +export const updateEdgeParameters = Type.Object({ + ...edgeFields, + executionId: Type.Optional(text("Pin a completed historical source execution from braid_status; from must match that execution's node ID. Available only in braid_update, never in the initial graph.")), +}, { additionalProperties: false }); +export const loopParameters = Type.Object({ + id: text("Unique loop ID referenced by exactly one edge.feedback."), + entry: text("Node ID where every iteration starts; the feedback edge must target this node."), + maxIterations: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total rounds including the first, not the number of retries. Choosing feedback on the last round fails with LOOP_LIMIT." }), +}, { additionalProperties: false, description: "A bounded loop with one entry and a decision that selects retry or exit. The body is acyclic after removing the feedback edge; external edges enter only at entry and leave only from that decision. Loops cannot overlap or nest." }); +const templateDescription = "Named prompt strings. Placeholders use {{name}} with names matching [A-Za-z_][A-Za-z0-9_]*. Node variables must match exactly; string values are inserted literally."; +const templates = Type.Record(Type.String(), text(), { description: templateDescription }); +export const braidParameters = Type.Object({ + goal: text("Shared goal included in every worker's context."), + nodes: Type.Array(nodeParameters, { minItems: 1 }), + edges: Type.Array(edgeParameters, { description: "Dependencies between node IDs; use [] for independent roots. Cycles require a declared loop and explicit feedback edge. Historical executionId is not allowed at submission." }), + promptTemplates: Type.Optional(templates), loops: Type.Optional(Type.Array(loopParameters)), + options: Type.Optional(Type.Object({ + maxConcurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneous node executions; default 4." })), + maxExecutions: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total execution limit across all iterations and updates; default 1000." })), + nodeTimeoutMs: timeout("node"), graphTimeoutMs: timeout("graph"), maxToolRounds: toolBudget("rounds"), maxToolCalls: toolBudget("calls"), + }, { additionalProperties: false })), +}, { additionalProperties: false }); + +const BRAID_FILESYSTEM_GUIDANCE = + "Every execute/decision activation gets a fresh worktree in Git, based on its predecessor execution checkpoint (roots use the initial job snapshot). Set workspace=read-only to disable writes. Multiple independent code snapshots require an explicit merge node. " + + "merge combines selected predecessor checkpoints into a new isolated worktree; integrate writes selected changes to the invoking checkout while preserving user edits. Workers apply changes with Git/file tools, then call finish_merge using executionId for each source; finish_merge only records dispositions and does not apply changes. There is no automatic final integration. " + + "Do not set workspace on merge/integrate nodes. Checkpoints remain recoverable after cleanup. Optional failed predecessors pass errors and partial work along unconditional edges; requireSuccess=true makes failure abort the job. " + + "Nodes have local read/ls/grep/find and Git inspection; writable nodes have write/edit and shell tools for dependencies, builds, and tests, while merge/integrate also have local Git integration tools. Search tools require local rg. Outside Git all filesystem access is read-only; read-only nodes have no shell. Worktrees are not an OS sandbox: prompts constrain shell writes and shared resources. Checkpoints omit ignored new files. Parent extension/MCP tools and recursive Braid calls are not provided. Nodes should verify their changes; the parent reviews results and performs any remaining validation after integration."; + +export const BRAID_USAGE_GUIDANCE = [ + "Braid is a proactive execution primitive, not only a user-requested command.", + "Selection rule: for a code review, bug investigation, design comparison, test-planning request, or change spanning multiple files, call braid FIRST when two or more concerns can be handled independently. Nodes can analyze the project and implement changes in isolated Git worktrees. Do this without waiting for the user to say Braid; do not read everything in the parent and then decide whether to delegate.", + BRAID_FILESYSTEM_GUIDANCE, + "For repeated instructions, define promptTemplates once and use prompt={template: name, variables: {name: value}} on nodes. Values are strings inserted literally into {{name}} placeholders; plain-string prompts remain supported.", + "When Braid fits, submit a graph: use parallel execute nodes for independent analysis or implementation, execute nodes to synthesize findings, merge nodes to combine code snapshots, and integrate nodes to apply changes to the working branch. The tool returns a jobId immediately. Continue independent work or finish your turn while it runs; do not poll repeatedly. A completion reminder will resume you. Use braid_status with the jobId to retrieve terminal outputs before relying on them.", + "Set notifyOnCompletion=true on selected nodes to receive intermediate success/failure reminders. Use braid_status({jobId, executionId}) to retrieve the exact execution; nodeId selects the latest instance. Use pauseAfter=true to hold outgoing scheduling. Definitions can always be changed with braid_update using expectedRevision; existing executions retain their captured inputs. Use resume in the same update to apply changes and release held executions atomically, or braid_resume for no graph changes.", + "Make the delegation choice once per user request. Reminders and definition errors are not new tasks. Check the accepted node types and settings in the tool response. If the same definition problem recurs, stop resubmitting and report the concrete mismatch; do not launch repeated probe/replacement jobs. In braid_update, add new nodes together with their dependencies and loops: a node added without incoming edges can start immediately, even while another execution is paused.", + "Do not use braid for a simple one-step answer, a trivial direct edit, a single shell command, or when decomposition adds no value. Writable nodes can implement and test their work. The parent reviews results and performs any remaining validation after Braid completes.", +].join("\n"); diff --git a/integrations/dsh/shell-tools.ts b/integrations/dsh/shell-tools.ts new file mode 100644 index 0000000..99a898a --- /dev/null +++ b/integrations/dsh/shell-tools.ts @@ -0,0 +1,75 @@ +import { execFile, spawn } from "node:child_process"; +import { constants } from "node:os"; +import { join } from "node:path"; +/** Own the entire process group through completion, cancellation, and timeout. */ +export async function runCommand(shell: string, args: string[], cwd: string, signal: AbortSignal, timeout?: number): Promise { + let output = ""; + const onData = (data: Buffer) => { output = (output + data.toString()).slice(-50_000); }; + signal?.throwIfAborted(); + if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0 || timeout * 1000 > 2_147_483_647)) + throw new Error("Invalid timeout: expected positive seconds within the timer limit"); + + const child = spawn(shell, args, { + cwd, detached: process.platform !== "win32", windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const closed = new Promise(resolve => child.once("close", () => resolve())); + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }); + child.stdout!.on("data", onData); + child.stderr!.on("data", onData); + let termination: Promise | undefined; + const terminate = (): Promise => termination ??= (async () => { + if (!child.pid) return; + if (process.platform === "win32") { + await new Promise((resolve, reject) => { + execFile(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), + ["/F", "/T", "/PID", String(child.pid)], { windowsHide: true }, error => { + // taskkill reports 128 when the process has already exited. + if (error && error.code !== 128) reject(error); + else resolve(); + }); + }); + } else { + try { process.kill(-child.pid, "SIGKILL"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + })(); + // The completion path awaits termination; event handlers must not create + // unhandled rejections while the shell is still exiting. + const stop = () => { void terminate().catch(() => {}); }; + let timedOut = false; + const timer = timeout === undefined ? undefined : setTimeout(() => { + timedOut = true; + stop(); + }, timeout * 1000); + signal?.addEventListener("abort", stop, { once: true }); + if (signal?.aborted) stop(); + let exitCode: number | null; + try { + exitCode = await exited; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", stop); + // Stop leftover children even on successful command completion. Commands + // cannot leave a server/watch process writing during checkpoint/cleanup. + try { await terminate(); } + finally { + // A daemon can leave the process group while holding inherited pipes. + // Bound pipe draining; this process-group cleanup is not an OS sandbox. + const drainTimer = setTimeout(() => { + child.stdout!.destroy(); + child.stderr!.destroy(); + }, 250); + try { await closed; } + finally { clearTimeout(drainTimer); } + } + } + if (signal?.aborted) throw new Error("aborted"); + if (timedOut) throw new Error(`timeout:${timeout}`); + return JSON.stringify({ output, exitCode: exitCode ?? (child.signalCode ? 128 + (constants.signals[child.signalCode] ?? 0) : 1) }); +} diff --git a/integrations/dsh/test/client-bundle.test.mjs b/integrations/dsh/test/client-bundle.test.mjs new file mode 100644 index 0000000..40c543d --- /dev/null +++ b/integrations/dsh/test/client-bundle.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { JSDOM } from "jsdom"; +import * as cordis from "@deepseek-ai/cordis"; +import * as React from "react"; +import * as jsx from "react/jsx-runtime"; +const require = createRequire(import.meta.url); +function bundle(file) { + let plugin; + const shared = { "@deepseek-ai/cordis": cordis, react: React, "react/jsx-runtime": jsx }; + new Function("window", readFileSync(file, "utf8"))({ __ModuleLoader__: { load({ factory }) { + plugin = factory(id => { assert.ok(id in shared, `Unexpected browser external: ${id}`); return shared[id]; }); + } } }); + assert.equal(typeof plugin.apply, "function"); return plugin; +} + +test("built browser factory mounts native Remote namespace and tool/sidebar slots; unload withdraws services/styles", async () => { + const dom = new JSDOM(""); + globalThis.document = dom.window.document; + const ctx = new cordis.Context(); + const entries = new Map(), tabs = new Map(), calls = []; + const frame = { rows: [], job: null }; + try { + await ctx.plugin(bundle(require.resolve("@deepseek-ai/dsh-typert-registry/client"))); + ctx.provide("connection", { + registerGenerationSource: () => () => {}, start: () => ({ stop() {} }), + rpc: { + async call(base, endpoint, payload) { calls.push({ base, endpoint, payload }); return { ok: true, value: frame }; }, + async *open(base, endpoint, payload, signal) { + calls.push({ base, endpoint, payload }); yield frame; + if (!signal.aborted) await new Promise(resolve => signal.addEventListener("abort", resolve, { once: true })); + }, + }, + }); + await ctx.plugin(bundle(require.resolve("@deepseek-ai/dsh-api-gateway/client"))); + // Slot owner boundaries are represented here; the real browser renderer supplies their props. + ctx.provide("slots", { + inject: (_name, register) => register(), + register: (definition, Component) => { entries.set(`${definition.name}/${definition.key ?? ""}`, { definition, Component }); return () => { entries.delete(`${definition.name}/${definition.key ?? ""}`); }; }, + }); + let opened; + ctx.provide("sidebarRight", { openTab: kind => { opened = kind; }, openTabIn: (sessionId, kind, options) => { opened = { sessionId, kind, options }; } }); + ctx.provide("sidebarRightTabs", { register: definition => { tabs.set(definition.id, definition); return () => { tabs.delete(definition.id); }; } }); + const fiber = await ctx.plugin(bundle(new URL("../dist/client.js", import.meta.url))); + for (let i = 0; i < 30 && entries.size !== 7; i++) await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal(entries.size, 7); assert.equal(tabs.get("@chrok/dsh-braid").kind, "braid"); + entries.get("conversation.session.header.actions/").definition.inject().open(); + assert.equal(opened, "braid"); + for (const name of ["braid", "braid_status", "braid_update", "braid_resume", "braid_cancel"]) { + const entry = entries.get(`tool.call.toolview/${name}`); + assert.ok(entry, `Missing tool renderer: ${name}`); + entry.definition.inject("tool-owner").openBraid({ jobId: "recorded-job", executionId: "review@1" }); + assert.deepEqual(opened, { sessionId: "tool-owner", kind: "braid", options: { params: { jobId: "recorded-job", executionId: "review@1" } } }); + } + const face = entries.get("sidebar.right.pane.tab/@chrok/dsh-braid").definition.inject("owner-session"); + assert.equal(face.sessionId, "owner-session"); + const request = { sessionId: face.sessionId, jobId: "job-uuid", revision: 2, action: "resume", executionIds: ["review@1"] }; + assert.deepEqual(await face.api.control(request), frame); + assert.deepEqual(JSON.parse(JSON.stringify(calls.at(-1))), { base: "/api", endpoint: "braidPanel/control", payload: { args: { request } } }); + const abort = new AbortController(); + const iterator = face.api.watch({ sessionId: face.sessionId }, abort.signal)[Symbol.asyncIterator](); + assert.deepEqual((await iterator.next()).value, frame); + abort.abort(); await iterator.return(); + assert.ok(document.querySelector('style[data-plugin="@chrok/dsh-braid"]')); + await fiber.dispose(); + assert.equal(entries.size, 0); assert.equal(tabs.size, 0); + assert.equal(document.querySelector('style[data-plugin="@chrok/dsh-braid"]'), null); + assert.equal(ctx.get("remote.braidPanel"), undefined); + } finally { await ctx.fiber.dispose(); dom.window.close(); delete globalThis.document; } +}); diff --git a/integrations/dsh/test/client/panel.test.tsx b/integrations/dsh/test/client/panel.test.tsx new file mode 100644 index 0000000..07d4609 --- /dev/null +++ b/integrations/dsh/test/client/panel.test.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { BraidPanelView } from "../../client/Panel.js"; +import { layoutGraph } from "../../client/layout.js"; +import type { ControlRequest, PanelApi, PanelFrame } from "../../panel-types.js"; + +const fixture: PanelFrame = { + rows: [{ jobId: "uuid-a", handle: "job-1", goal: "Review and implement", status: "running", createdAt: 0 }], + job: { jobId: "uuid-a", handle: "job-1", goal: "Review and implement", status: "running", createdAt: 0, + revision: 4, phase: "waiting", nodes: [{ id: "review", type: "execute", status: "completed" }, { id: "apply", type: "integrate", status: "pending" }], + edges: [{ from: "review", to: "apply" }], loops: [], pausedExecutionIds: ["review@1"], + usage: { inputTokens: 100, outputTokens: 25, cacheReadTokens: 10, cacheWriteTokens: 0 }, + executions: [{ id: "review", executionId: "review@1", revision: 3, status: "completed", type: "execute", iteration: 1, latencyMs: 1800 }], + events: [{ sequence: 1, timestamp: 0, type: "execution_paused", executionId: "review@1", message: "execution paused" }], + }, +}; +async function setup() { + const dom = new JSDOM("
", { url: "http://localhost" }); + Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); + const root = createRoot(document.getElementById("root")!); + const controls: ControlRequest[] = []; + const signals: AbortSignal[] = []; + const sessions: string[] = []; + let fail = false; + const api: PanelApi = { + async *watch(request, signal) { + signals.push(signal); sessions.push(request.sessionId); + if (fail) throw new Error("Connection lost"); + yield request.sessionId === "empty" ? { rows: [], job: null } : fixture; + await new Promise(resolve => signal.addEventListener("abort", () => resolve(), { once: true })); + }, + control: async request => { controls.push(request); return fixture; }, + detail: async request => ({ executionId: request.executionId, status: "completed", output: "\nReview passed", offset: 0, next: 42, total: 42 }), + }; + const render = async (sessionId = "owner") => { await act(async () => { root.render(); }); }; + const click = async (label: string) => { + const button = [...document.querySelectorAll("button")].find(b => b.textContent === label || b.getAttribute("aria-label") === label); + assert.ok(button, `Missing ${label}`); await act(async () => button.click()); + }; + return { dom, root, api, controls, signals, sessions, render, click, disconnect: () => { fail = true; }, + close: async () => { await act(async () => root.unmount()); dom.window.close(); } }; +} + +test("panel renders DAG, output as text, exact-revision resume and cancel; switching session aborts observation", async () => { + const h = await setup(); + try { + await h.render(); + assert.match(document.body.textContent!, /Review and implement/); + assert.equal(document.querySelectorAll(".br-node").length, 2); + assert.equal(document.querySelector(".br-output script"), null); + assert.match(document.querySelector(".br-output")!.textContent!, /" })); + await click(".br-tool-open"); + assert.deepEqual(opened[0], { jobId: "uuid-1", executionId: "review@1", createdBefore: 2000 }); + await click(".br-tool-actions button"); + assert.equal(document.querySelector(".br-tool-details script"), null); + assert.match(document.querySelector(".br-tool-details")!.textContent!, /