Skip to content

Latest commit

 

History

History
104 lines (76 loc) · 6.3 KB

File metadata and controls

104 lines (76 loc) · 6.3 KB

How it is put together

中文

Two halves

package.json        dual declaration: dsh.bundle (host) + dsh.client (browser)
src/org.ts          pure projection: session events → the agency + its timeline (no imports, no IO)
src/index.ts        Cordis host: event subscription, policy observation, the /abyss endpoints
client/client.js    the embedded panel (hand-authored, no build step)
lib/index.js        the host half, transpiled
tests/              113 cases

Why the host half is built: dsh runs plugins under plain Node, and Node refuses to strip TypeScript inside node_modules. A plugin shipping a .ts entry works on its author's machine (where pnpm dsh runs under tsx) and fails the moment a user installs it. So the host half is transpiled to lib/index.js; the browser half is already plain JavaScript.

Zero runtime dependencies: @deepseek-ai/cordis is a type-only import erased at build time; only node: builtins are used at runtime.

The projection

src/org.ts is a set of pure functions reducing a session event stream into an agency. It knows nothing of the network, the filesystem, or cordis.

Events consumed (dsh declares 47; this reads 20 of them):

Event Projected into
subagent/descriptor a hire: job title, firm, staff vs temp
session/title the case's name (the same one the sidebar shows)
request/header / request/context model route, context window
turn/start / turn/end a shift on the attendance record
assistant/chunk thinking / speaking
assistant/message the spoken line, tokens, wage, context load
tool/call activity; subagent → assign, send_message → message, report → report
tool/result failures (error or the result block's isError)
tool-workflow/agent-start|end members a workflow fanned out
approval/asked / approval/decided waiting on a human, and the measured wait
llm/retry a dropped call redialled
compaction/start / compaction/summary archiving, and how many tokens went away
todo/write the member's own task list

Principles:

  • Every spoken line is text from the log. With no text to read, the projection changes state rather than inventing a sentence.
  • Returning the same reference means nothing changed, which is how the host decides whether to broadcast.
  • No guessing: no context gauge without an advertised window; a non-zero shell exit is not a failure (dsh reports it as ordinary output, and sniffing that text would make every grep with no match an incident).

A case is a session tree

process (a dsh instance)   the agency
 └─ workspace (cwd)         a project
     └─ session tree        a CASE: the root session plus every subagent under it
         └─ agent session   a member

Several sessions in one project are several parallel cases, not one flat roster — hence the three scopes (this case / this project / all), which every tab and the footer follow.

Past maxTeams, the host evicts only cases where no member is still on staff.

Data endpoints

All served on the product's own origin under /abyss, with no CORS header — a permissive one would let any site you visit read your session titles, tool names and spend.

Endpoint Purpose
GET /abyss/state snapshot (members, scenes, org chart, theme)
GET /abyss/events SSE stream: snapshot / staff / scene / gone
GET /abyss/replay?team=<id> rebuild a case from the durable logs (used both to replay and to rehydrate an office)
GET /abyss/report?team=<id> the same rebuild rendered as a Markdown write-up
GET /abyss/cases the archive: cases on disk (headers only, no logs opened)

A composition with no web server (headless, ACP) can opt into a standalone data port with port; the office view itself is a Web UI feature.

How the durable side is read

  • Live: ctx.on('session/event').
  • History: ctx.sessionPersistence first, falling back to ctx.sessions (only sessions open in this process). This is the entire reason an office survives a restart — reading only the live store 404s every case from yesterday.
  • A read that fails is reported: a corrupt log (a seq gap, say) comes back as unreadable, and both the write-up and the replay reel say so. A report whose totals are quietly too small is worse than no report.

The browser half

  • A hand-authored lazy-CJS bundle registered through window.__ModuleLoader__ and served by the product under /plugins.
  • Everything interpolated is escaped: session titles, tool names and scene lines are model output, so one unescaped quote is an injection into the product's own page.
  • Rendering is throttled to one animation frame and bounded: at most 12 cards per case on screen, at most 300 scenes in the browser.
  • Transient state lives in the model, never only in the DOM — every render replaces the floor's markup.
  • The org chart is derived in the panel, not taken from the wire; otherwise an office rebuilt from disk shows seven people on the floor and an empty chart.
  • Theme follows body[data-ds-dark-theme], language follows html[lang], motion respects prefers-reduced-motion.

Testing

npm test        # 113: projection 46 + host wiring 15 + browser 52
  • The browser tests drive the shipped bundle, not a copy of its logic. Every client defect this project has hit — a shadowed global, an unescaped quote, DOM-only state, an inverted fold toggle, a motion class landing on text — is invisible to a test that reimplements the code.
  • Host tests drive apply() with real event shapes: endpoints, SSE, config validation, the persistence fallback, corrupt logs.
  • Live runs against the real product: watching the panel during real tasks, three concurrent sessions, a real approval escalation and real tool failures — with the plugin's numbers reconciled line by line against the raw logs.

Known limits

  • The live floor is in memory and rebuilt from disk after a restart; compaction and llm/retry have unit coverage but no live evidence (they occur 0 and 1 times in the real logs here).
  • A flying note needs both desks on screen; otherwise the sender still speaks.
  • Replay paces by compressed real gaps, so long idle stretches are skipped rather than waited out.