diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 9c5d00eae0..820299cf2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc -2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc +2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3 +2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index cce649976c..ca56c77a09 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -20,7 +20,7 @@ AgentLoop privately owns one `TurnCancellation` per prospective turn. It install The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. -The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. +The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. @@ -44,7 +44,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and **Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning. -**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. +**Expose public turn or step context wrappers.** Existing seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. **Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 6f8b83fdb4..bf410e5c72 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 @@ -44,7 +44,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 **现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 -**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 +**公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 **在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 37eccb0c95..9c8308be74 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: aeefbe22a397e3d7ffb9f6427a3c70c8c8e8b940 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 056d50d45cef891e0e635d8bb4f2e73064ccdb87 +2026-07-25-web-client-session-scope-and-provide-channel.md: 3c51f06fca23a495f0fbc0cc4f1c289edea07b3b +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 06fc1005785d9d11b52839f91c3bb4b99cad7d63 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index aeefbe22a3..3c51f06fca 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -93,7 +93,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 056d50d45c..06fc100578 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -93,7 +93,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 -`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index a52995c855..f22f2340ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 977df6508e1a1cd54cf1ddb469a6bfb835f60071 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: f70065c8b356b2ed5ca6ab317fbdeb5177f058fa +2026-07-25-web-input-machine-and-slash-pipeline.md: 39ef214a94fcd019f535fb60136d5dcc09b54e60 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 9b0ca0cadbc5e0212048b165f0d60d567a5639ad diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 977df6508e..39ef214a94 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -69,9 +69,9 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": ### hub / facade: the resident shell and the strict-session input body - The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation. -- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. +- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears. - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. -- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. +- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. - Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee. @@ -93,9 +93,10 @@ skill/@subagent references skip the placeholder + occurrence identity chain — ### The slot system -`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The child slots are all declared by ui-conversation's conversation registration: +`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The root registration renders the header outlet above its resident scrollport and the body outlet inside it, before the resident composer seat. The child slots are all declared by ui-conversation's conversation registration: -- `conversation.session` (single) — the strict-session header, view ring, and chat store; rebuilt when the session id switches. +- `conversation.session.header` (single) — strict-session breadcrumb, view tabs, and header actions above the resident scrollport. +- `conversation.session` (single) — the strict-session view ring and draft mirror inside the resident scrollport. Header and body share the same session-scoped chat store; each is rebuilt when the session id switches. - `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival. - `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId. - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. @@ -128,7 +129,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ ## Consequences -- One resident conversation shell carries no-session/blank/active: no session → blank guarantees only the outer frame's React identity, allowing the disabled textarea to be replaced by the strict InputBar; the same blank session → engaging/active keeps the InputBar and the textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. +- One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and textarea; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. - The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract. - Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests. - Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index f70065c8b3..9b0ca0cadb 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -69,9 +69,9 @@ occurrence 表与 chip 三投影: ### hub / facade:常驻外壳与严格 session 输入体 - hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。 -- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。 +- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;Session 出现后,彼此独立的严格 session header 和 body outlet 只填入这些固定区域。 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 -- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 +- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的 Session 在任何 open state 下都保持 Hero,未经证实的 Session 则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 - 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。 @@ -93,9 +93,10 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 ### slot 体系 -`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。子 slot 均由 ui-conversation 的 conversation 注册声明: +`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。root 注册把 header outlet 渲染在常驻 scrollport 上方,把 body outlet 渲染在其内部、常驻 composer seat 之前。子 slot 均由 ui-conversation 的 conversation 注册声明: -- `conversation.session`(single)——严格 session 的 header、view ring 与 chat store;session id 切换时重建。 +- `conversation.session.header`(single)——常驻 scrollport 上方严格 session 的 breadcrumb、view tab 与 header action。 +- `conversation.session`(single)——常驻 scrollport 内严格 session 的 view ring 与 draft mirror。header 和 body 共享同一个 session scope chat store;session id 切换时各自重建。 - `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。 - `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 @@ -128,7 +129,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 ## 后果 -- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 只保证大框架 React identity,允许 disabled textarea 替换为严格 InputBar;同一 blank session → engaging/active 保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 +- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea;只有严格 session header 和 body outlet 开始承载内容。同一 blank session → engaging/active 也保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 - 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。 - 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。 - 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml new file mode 100644 index 0000000000..eed6bee5f0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee +2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md new file mode 100644 index 0000000000..11a8ac3d40 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -0,0 +1,33 @@ +# Agent Note: Profile plugin bundles replace the fixed surface overlays + +Status: implemented + +English | [中文](2026-08-05-profile-plugin-bundles.zh.md) + +## Problem + +The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.yml` shipped inside `apps/cli`, three bespoke entry modes (`--config`, `web`, `-p`) each with its own layer stack, and a single global personal overlay (`$DSH_HOME/config.yaml`). There was no way to install an out-of-tree plugin (a TUI, a provider pack) into a shipped surface without editing the repository, and no place where a third-party package could contribute a default composition. + +## Decision + +Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. + +The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). + +Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). + +Two supporting refactors: the webserver's built-in static dist serving became the single-owner **fallback seat** (`registerFallback`/`applyIndexTaps`), with the SPA server extracted to `@deepseek-ai/dsh-frontend-static` so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the [dsh CLI personal-config decision](../feature/2026-07-20-dsh-cli-personal-config.md) (`loadPersonalPatches`, `$DSH_HOME/config.yaml`) was retargeted to the per-profile and home-level `cordis.patch.yml` layers (`loadOptionalPatches`, `watchUserPatches` taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing. + +## Alternatives considered + +- **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan. +- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony. +- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` seam is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. +- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. + +## Consequences + +- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile; the repository no longer needs a row for every deployment shape. +- `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone. +- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly. +- Backends reject nothing old on disk (pre-release stance): `$DSH_HOME/config.yaml` is simply no longer read. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md new file mode 100644 index 0000000000..0e9ebf657c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -0,0 +1,33 @@ +# Agent Note: profile 插件组合包取代固定的表层 overlay + +Status: implemented + +[English](2026-08-05-profile-plugin-bundles.md) | 中文 + +## Problem + +`dsh` 启动器硬编码了自己的组合:`base.cordis.yml` + `web.cordis.yml` 随 `apps/cli` 一起交付,三种各自定制的入口模式(`--config`、`web`、`-p`)各带一套层栈,外加一个全局的个人 overlay(`$DSH_HOME/config.yaml`)。想把树外插件(一个 TUI、一个提供方扩展包)装进已交付的表层,只能修改仓库;第三方包也没有任何位置可以贡献默认组合。 + +## Decision + +一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 + +已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 + +解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 + +两项配套重构:webserver 内置的静态 dist 服务改为单一所有者的**回退席位**(`registerFallback`/`applyIndexTaps`),SPA 服务器提取到 `@deepseek-ai/dsh-frontend-static`,使 web 组合包以组合的方式持有自己的 dist,而不是靠启动器代码;[dsh CLI 个人配置决策](../feature/2026-07-20-dsh-cli-personal-config.md)的个人 overlay 机制(`loadPersonalPatches`、`$DSH_HOME/config.yaml`)改为面向逐 profile 与 home 级的 `cordis.patch.yml` 层(`loadOptionalPatches`、接受文件名的 `watchUserPatches`),取代该笔记的各入口模式与文件位置,同时保留其 Harness home 根目录、patch 语义与大声失败的解析。 + +## Alternatives considered + +- **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。 +- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。 +- **在组合包 manifest(元数据清单)中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` seam 是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 +- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 + +## Consequences + +- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装;仓库不再需要为每种部署形态各留一行。 +- `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。 +- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会大声失败。 +- 后端不拒绝磁盘上的任何旧格式(发布前姿态):`$DSH_HOME/config.yaml` 只是不再被读取。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml new file mode 100644 index 0000000000..b6e58aabc7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md +2026-08-06-agent-event-payload-objects.md: 470c8fb3f9282005829846307778d3d1088c3888 +2026-08-06-agent-event-payload-objects.zh.md: ff201a7c3134c0ef809c9a798d65412541f9f1e7 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md new file mode 100644 index 0000000000..470c8fb3f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md @@ -0,0 +1,27 @@ +# Agent Note: Agent-scoped events dispatch a single payload object + +Status: implemented + +English | [中文](2026-08-06-agent-event-payload-objects.zh.md) + +## Problem + +Agent-scoped events historically took positional arguments: a leading `agent` subject, event-specific fields, and a trailing `next` for waterfall/serial events. Adding a field or retiring a context type (as with `PreStepContext` and `RequestFailureContext`) rewrote every listener and emitter across packages, and the contract stayed spread across the parameter list instead of one named payload. + +## Decision + +Every agent-scoped event takes exactly one payload object as its first argument. The payload always carries the subject (`agent`), the event's fields, and the cancellation `signal` when the event has one; `next` remains the last argument of waterfall/serial events. The affected events are the twelve `agent/*` events, `agent-loop/config-start-failed` (the only one without a subject), and `goal/changed`. + +`PreStepContext` and `RequestFailureContext` are retired; their fields live directly in the `agent/pre-step` and `agent/request-error` payloads. + +Dispatch is fused: `agentEvents(ctx, agent)` (and the one-shot `emitAgentEvent`) injects the subject so the scope carrier key and the payload's `agent` cannot diverge, and the injected subject wins even over a structurally acceptable payload that happens to carry an `agent` field. `ReactLoopAgent` builds its dispatcher once in the constructor and routes every emit, serial, and waterfall through it, so hot-path dispatches allocate nothing. + +## Alternatives considered + +**Keep positional signatures.** Adding a field or retiring a context type would keep rewriting every listener and emitter, and the contract would stay spread across the parameter list instead of one named payload. + +**Hand-build the subject at each dispatch site.** The loop's intermediate design called `ctx.waterfall(this.carrier, …)` with a manually constructed `{ agent: this, … }` payload; it avoided per-dispatch allocation but duplicated the subject injection and let the scope key and the payload subject diverge. The fused dispatcher is the single injection point for every dispatch mode. + +## Consequences + +Listener signatures name the full payload once, so extending a payload or retiring a context type is a one-shape change across all listeners and emitters. The subject/scope coupling is enforced by the dispatcher for every dispatch mode, and the loop's hot paths stay allocation-free. diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md new file mode 100644 index 0000000000..ff201a7c31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Agent 作用域事件 dispatch 单个 payload 对象 + +Status: implemented + +[English](2026-08-06-agent-event-payload-objects.md) | 中文 + +## 问题 + +Agent 作用域事件历来采用位置参数:开头的 `agent` 主体、事件专属字段,以及末尾用于 waterfall(瀑布式事件)/serial 事件的 `next`。新增字段或退役上下文类型(如 `PreStepContext` 与 `RequestFailureContext`)都会迫使跨包重写每个监听器和 emitter,契约也一直分散在参数列表中,而不是集中在一个具名 payload 中。 + +## 决策 + +每个 agent 作用域事件都将恰好一个 payload 对象作为其第一个参数。payload 始终携带主体(`agent`)、事件的字段,以及事件有取消信号时的取消 `signal`;`next` 仍然是 waterfall/serial 事件的最后一个参数。受影响的事件是十二个 `agent/*` 事件、`agent-loop/config-start-failed`(唯一没有主体的事件)以及 `goal/changed`。 + +`PreStepContext` 与 `RequestFailureContext` 已退役;它们的字段直接存在于 `agent/pre-step` 与 `agent/request-error` 的 payload 中。 + +dispatch 是融合的:`agentEvents(ctx, agent)`(以及一次性 `emitAgentEvent`)注入主体,使作用域载体键与 payload 的 `agent` 不可能分叉;即使某个结构上可接受的 payload 恰好携带 `agent` 字段,注入的主体仍然优先。`ReactLoopAgent` 在构造函数中构建一次 dispatcher,并将每个 emit、serial 和 waterfall 都经由它路由,因此热路径上的 dispatch 不产生任何分配。 + +## 考虑过的替代方案 + +**保留位置签名。** 新增字段或退役上下文类型依旧会重写每个监听器和 emitter,契约也会继续分散在参数列表中,而不是集中在一个具名 payload 中。 + +**在每个 dispatch 位置手工构造主体。** loop 的中间设计调用 `ctx.waterfall(this.carrier, …)`,传入手工构造的 `{ agent: this, … }` payload;它避免了每次 dispatch 的分配,却重复了主体注入,并让作用域键与 payload 主体分叉。融合的 dispatcher 是每种 dispatch 模式的唯一注入点。 + +## 后果 + +监听器签名一次性命名完整 payload,因此扩展 payload 或退役上下文类型,对所有监听器和 emitter 都是一次形状变更。主体/作用域耦合由 dispatcher 在每种 dispatch 模式下强制执行,且 loop 的热路径保持零分配。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml new file mode 100644 index 0000000000..815f5eee75 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md +2026-08-06-web-shell-dist-chunk-layout.md: 1c7b4273dc243685317b149e2fd7fddf2a6c18d1 +2026-08-06-web-shell-dist-chunk-layout.zh.md: 6f4b94e0bd7412e480458e34922273b389aa8892 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md new file mode 100644 index 0000000000..1c7b4273dc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md @@ -0,0 +1,50 @@ +# Agent Note: Web shell dist chunk split and directory layout + +Status: implemented + +English | [中文](2026-08-06-web-shell-dist-chunk-layout.zh.md) + +## Problem + +The apps/web shell previously built into a single ~1.2 MB (minified) index chunk, roughly 80% of it vendor bytes — KaTeX, the boot grammars and the shiki engine, react-dom, the markdown pipeline — fused with all the workspace shell code (about one fifth). Any one-line shell change rehashed the whole chunk, forcing returning clients to redownload everything; `dist/assets/` was a flat single-level spread of 100-plus files (the main chunk, 23 lazy-loaded grammar chunks, 59 KaTeX font faces, and sourcemaps intermixed), impossible to navigate. + +## Decision + +`apps/web/vite.config.ts` splits the shell into two initial chunks via `manualChunks` and sorts the output into directories via naming functions; the entire configuration contains zero regexes — an exact-package-name Set, a filename list, an extension list. + +**Membership** (`VENDOR_PACKAGES`, by exact npm package name): + +- `vendor` = the three heavy rendering families: math (katex), highlight (shiki), markdown (the micromark/mdast parse pipeline — the incremental React renderer above it is workspace code and not part of this). The live membership is `VENDOR_PACKAGES`; the list is the packages workspace code **imports directly**: the remaining private transitive dependencies (the oniguruma family, @shikijs/core, character tables, dozens more) are referenced only by listed members, so rollup's chunk coloring pulls them into vendor automatically; dependencies shared with the index side fall back to index, diluting it by a few KB — not a correctness issue. +- **Every vendor member must be react-free (the boundary invariant)**: rollup folds a module shared between the entry and a manual chunk into the manual chunk — one listed package importing react/jsx-runtime would drag the single shared react copy into vendor, away from index. The React side of markdown/math rendering is workspace code and naturally lives in index, so the whole react family stays pinned to index. +- `index` (the default chunk) = the react family (react, react-dom, scheduler, use-sync-external-store), vendored cordis, all workspace code, and the unlisted small pieces (anser, clsx). +- `@shikijs/langs` is special-cased: the boot grammars (`BOOT_GRAMMAR_FILES`: typescript, shellscript, json — the three that highlight.ts statically imports, all self-contained data modules with zero internal imports) go into vendor; the remaining 23 lazy-loaded grammars get no assignment and each keeps its own on-demand chunk. +- `index.html` is wired up automatically by vite: index loads via `')) + for (const path of ['/', '/index.html', '/no/such/route']) { + const got = await request(port, path) + expect(got.status).toBe(200) + expect(got.body).toContain('__T__') + expect(got.body).toContain('shell') + } + untap() + expect((await request(port, '/')).body).not.toContain('__T__') + + // Traversal outside the dist root is 403; non-GET/HEAD is 405. + expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) + expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) + + // HMR safety: disposing the frontend row releases the fallback seat (the + // unclaimed webserver answers 404) and the seat is claimable again. + const frontendEntry = [...loaded.loader.entries()].find(e => e.options.id === 'frontend') + expect(frontendEntry).toBeDefined() + await frontendEntry!.fiber?.dispose() + expect((await request(port, '/no/such/route')).status).toBe(404) + expect(() => server.registerFallback(() => {})).not.toThrow() + }) +}) diff --git a/packages/host/frontend-static/tsconfig.json b/packages/host/frontend-static/tsconfig.json new file mode 100644 index 0000000000..bda9b5bb40 --- /dev/null +++ b/packages/host/frontend-static/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../webserver" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 8b53e55af5..56fd0e7694 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/webserver/README.md -README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4 -README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977 +README.md: b6dccf2f81c9e2f0b9f53264eafe724edb560f07 +README.zh.md: dbfe420013ed67c48e47048341f020864aeef16a diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 196f350d87..b6dccf2f81 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. -The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. +A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. @@ -21,5 +21,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. -- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. - **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 0ae0470eab..dbfe420013 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 +Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 -该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的 route。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 +该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 -监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 @@ -21,5 +21,4 @@ Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配 ## 已知限制与延期工作 - **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。 -- **初始 MIME 表很精简**:Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。 - **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 6b46b8704d..a536f9e1f5 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,21 +1,19 @@ /** * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http * server plus the `httpServer` service (HTTP and upgrade route registries, - * index transform taps, and static dist fallback). Knows no harness concepts; - * feature plugins own every registered protocol. Web shape only — Electron - * loads dist over file:// and carries fetch over an IPC bridge. This package - * never prints: the URL line belongs to the shell. + * index transform taps, and the single fallback seat for everything no route + * claims). Knows no harness concepts and serves no files; the composing + * application's frontend plugin owns dist serving through the fallback seam. + * Web shape only — Electron loads dist over file:// and carries fetch over an + * IPC bridge. This package never prints: the URL line belongs to the shell. */ import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse, Server } from 'node:http' -import { readFile } from 'node:fs/promises' import type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' -import { dirname } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' -import { serveStatic } from './static.ts' declare module 'cordis' { interface Context { @@ -43,28 +41,26 @@ export interface WebUpgradeRoute { handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise } -/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ +/** Gateway config: the listen address. */ export interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number - /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ - distIndex: string } /** * The web-shape HTTP carrier service. Activation listens immediately (route * registration order carries no request-facing semantics: named routes are - * composed to be disjoint, and the static dist fallback answers anything not - * yet claimed during the boot window). A listen failure throws out of init — - * a FAILED fiber the boot's fail-loud sweep reports. + * composed to be disjoint, and the fallback seat answers anything not yet + * claimed during the boot window — 404 until its owner registers). A listen + * failure throws out of init — a FAILED fiber the boot's fail-loud sweep + * reports. */ export class HttpServerService extends Service { static Config: z = z.object({ host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(), port: z.natural().max(65535).required(), - distIndex: z.string().required(), }) private readonly exact = new Map() @@ -72,15 +68,12 @@ export class HttpServerService extends Service { private readonly upgrades = new Map() private readonly upgradedSockets = new Set() private readonly indexTaps: ((html: string) => string)[] = [] - private readonly distRoot: string - private readonly distIndex: string + private fallback: WebRoute['handler'] | undefined private server!: Server private listenedPort!: number constructor(ctx: Context, private config: Config) { super(ctx, 'httpServer') - this.distIndex = config.distIndex - this.distRoot = dirname(config.distIndex) } /** The listening port (the OS-assigned value when config.port is 0). */ @@ -123,8 +116,24 @@ export class HttpServerService extends Service { } /** - * Register an index.html transform, applied to every index response in - * registration order. + * Claim the fallback seat: the handler answering every request no named + * route matches (the SPA dist server in the shipped Web composition). One + * owner only — a second registration throws, because two fallbacks cannot + * compose. + * @param handler - owns the full response lifecycle of unmatched requests. + * @returns the disposer releasing the seat. + */ + registerFallback(handler: WebRoute['handler']): () => void { + if (this.fallback !== undefined) { + throw new Error('webserver: fallback already registered') + } + this.fallback = handler + return () => { this.fallback = undefined } + } + + /** + * Register an index.html transform, applied by the fallback owner to every + * index response ({@link applyIndexTaps}) in registration order. * @param transform - pure html-to-html function. * @returns the disposer removing the transform. */ @@ -147,14 +156,13 @@ export class HttpServerService extends Service { await route.handler(req, res) return } - // Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405, - // traversal 403, miss falls back to index.html 200 (SPA routing). - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) + const fallback = this.fallback + if (fallback === undefined) { + res.writeHead(404) res.end() return } - await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex()) + await fallback(req, res) } // Last-resort guard: handle() rejecting would otherwise be an unhandled // rejection killing the process on one malformed request (bad %-escape, @@ -243,11 +251,16 @@ export class HttpServerService extends Service { return best } - /** Index body: dist index.html through the registered taps in order. */ - private async renderIndex(): Promise { - let html = await readFile(this.distIndex, 'utf8') - for (const transform of this.indexTaps) html = transform(html) - return html + /** + * Run an index.html body through the registered taps in registration order + * — called by the fallback owner on every index response it renders. + * @param html - the raw index.html body. + * @returns the transformed body. + */ + applyIndexTaps(html: string): string { + let out = html + for (const transform of this.indexTaps) out = transform(out) + return out } } diff --git a/packages/host/webserver/src/static.ts b/packages/host/webserver/src/static.ts deleted file mode 100644 index a672f4e5c2..0000000000 --- a/packages/host/webserver/src/static.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Static file serving for the web shell: the starter MIME table and the - * request handler with the semantics locked by the step1 acceptance list — - * traversal outside the dist root is 403, any miss falls back to index.html - * with HTTP 200 (SPA routing), unknown extensions ship as octet-stream. - */ - -import type { ServerResponse } from 'node:http' -import { extname, join, normalize, resolve, sep } from 'node:path' -import { readFile } from 'node:fs/promises' - -const MIME: Record = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.svg': 'image/svg+xml', - '.json': 'application/json', - '.map': 'application/json', -} - -/** - * Serve one GET/HEAD static request from the dist root. - * @param pathname - decoded URL pathname of the request. - * @param res - the node:http response to write. - * @param distRoot - absolute dist root directory (resolved by the caller). - * @param distIndex - absolute path of index.html inside distRoot. - * @param renderIndex - when set, produces the index.html body (boot-manifest - * injection) for `/` and every SPA fallback; undefined serves the file verbatim. - */ -export async function serveStatic( - pathname: string, res: ServerResponse, distRoot: string, distIndex: string, - renderIndex?: () => Promise, -): Promise { - const target = resolve(normalize(join(distRoot, pathname))) - // Traversal rejection: the target must be distRoot itself (`/`) or stay under - // it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/' - // suffix would reject every legitimate subpath as traversal. - if (target !== distRoot && !target.startsWith(distRoot + sep)) { - res.writeHead(403) - res.end() - return - } - const serveIndex = async (): Promise => { - const body = renderIndex === undefined ? await readFile(distIndex) : await renderIndex() - res.writeHead(200, { 'content-type': MIME['.html'] }) - res.end(body) - } - if (target === distRoot || target === distIndex) { - await serveIndex() - return - } - try { - const body = await readFile(target) - res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' }) - res.end(body) - } catch { - // Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing). - await serveIndex() - } -} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 19a252d53a..d91284c87b 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -2,11 +2,10 @@ * REAL-composition coverage: a test-only cordis.yml booted through the * vendored Loader mounts the webserver row, and every assertion observes the * user-visible HTTP surface of the running server (routing precedence, index - * taps, static-fallback semantics, per-request error containment, teardown). + * taps, fallback-seat semantics, per-request error containment, teardown). */ import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { mkdir } from 'node:fs/promises' import { once } from 'node:events' import { connect } from 'node:net' import { tmpdir } from 'node:os' @@ -28,21 +27,15 @@ afterEach(async () => { root = undefined }) -/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */ +/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */ async function loadComposition(port = 0): Promise { root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-')) - const dist = join(root, 'dist') - await mkdir(dist) - const distIndex = join(dist, 'index.html') - await writeFile(distIndex, 'shell') - await writeFile(join(dist, 'app.js'), 'export {}') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-host-webserver'", ' config:', " host: '127.0.0.1'", ` port: ${String(port)}`, - ` distIndex: '${distIndex}'`, '', ].join('\n')) @@ -96,7 +89,7 @@ describe('real Loader composition', () => { // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough // to trip the default 5s budget on cold caches. - it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => { + it('serves registered routes, index taps, and the fallback-seat semantics', { timeout: 60_000 }, async () => { const loaded = await loadComposition() const unloaded = [...loaded.loader.entries()] .filter(entry => entry.fiber === undefined && !entry.disabled) @@ -120,21 +113,24 @@ describe('real Loader composition', () => { expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' }) expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' }) - // Index taps apply in registration order on `/` and on the SPA fallback; - // the disposer removes the transform. + // Fallback seat: 404 while unclaimed; the owner answers everything no + // named route matches; index taps are the owner's to apply; the seat + // admits exactly one owner and the disposer releases it. + expect((await request(port, '/no/such/route')).status).toBe(404) const untap = server.tapIndex(html => html.replace('', '')) - expect((await request(port, '/')).body).toContain('__T__') + expect(server.applyIndexTaps('')).toContain('__T__') + const releaseFallback = server.registerFallback((req, res) => { + // Decode like a real static server would — a malformed %-escape throws + // here, probing the webserver's per-request error containment. + decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname) + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(server.applyIndexTaps('shell')) + }) + expect(() => server.registerFallback(() => {})).toThrow(/fallback already registered/) expect((await request(port, '/no/such/route')).body).toContain('__T__') untap() - expect((await request(port, '/')).body).not.toContain('__T__') - - // Static fallback semantics: real asset served, traversal 403, non-GET/ - // HEAD without a matching route 405. - expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' }) - await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') - expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) - expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) - expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) + expect((await request(port, '/no/such/route')).body).not.toContain('__T__') + expect((await request(port, '/no/such/route')).body).toContain('shell') // Per-request error containment: a malformed %-escape answers 400 and the // server keeps serving afterwards (no process-level failure path). @@ -148,9 +144,14 @@ describe('real Loader composition', () => { const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } }) expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' }) disposeOnce() - expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback + expect((await request(port, '/once')).body).toContain('shell') // back to the fallback owner expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow() + // Releasing the seat restores the unclaimed 404 and registrability. + releaseFallback() + expect((await request(port, '/no/such/route')).status).toBe(404) + expect(() => server.registerFallback(() => {})).not.toThrow() + // Upgrade routes match exact pathnames, reject duplicate ownership, and // become registrable again after disposal. The accepted socket stays open // so the teardown assertion also covers upgraded-connection ownership. diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index fd756a39cc..620e367742 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -5,9 +5,9 @@ * @module @deepseek-ai/dsh-llm-retry */ -import type { Context } from 'cordis' +import type { Context, Events } from 'cordis' import z from 'schemastery' -import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -172,12 +172,9 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } async function recover( - agent: Agent, - context: RequestFailureContext, - signal: AbortSignal, + { agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters[0], next: () => Promise, ): Promise { - const { turn, step, provider, failure, retryPolicy: policy } = context if (policy === undefined) return next() if (policy.mode === 'always') { if (signal.aborted || lifetime.signal.aborted) return @@ -228,16 +225,14 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const disposeListener = ctx.on('agent/request-error', ( - agent: Agent, - context: RequestFailureContext, - signal: AbortSignal, + payload, next: () => Promise, ) => { // A waterfall may have captured this callback before its registration was // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve(undefined) - return track(recover(agent, context, signal, next)) + return track(recover(payload, next)) }) ctx.effect(() => async () => { diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index d1500fa781..ac0ed687fa 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -506,7 +506,7 @@ describe('provider-routed retry policy', () => { ;({ ctx: context } = await harness(adapter, { other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), }, (ctx) => { - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'other', })) @@ -543,7 +543,7 @@ describe('provider-routed retry policy', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1 }, }), }, (ctx) => { - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: adapter.requests.length === 0 ? 'mock' : 'other', })) @@ -881,7 +881,7 @@ describe('provider-routed retry policy', () => { context = mounted.ctx const downstream = Promise.withResolvers() const entered = Promise.withResolvers() - context.on('agent/request-error', (agent) => { + context.on('agent/request-error', ({ agent }) => { agent.cancel({ kind: 'user' }) entered.resolve(undefined) return downstream.promise @@ -917,7 +917,7 @@ describe('provider-routed retry policy', () => { const captured = Promise.withResolvers() let invokeCaptured: (() => Promise) | undefined const mounted = await harness(adapter, {}, (ctx) => { - ctx.on('agent/request-error', (_agent, _context, _signal, next) => { + ctx.on('agent/request-error', (_payload, next) => { return new Promise((resolve) => { invokeCaptured = async () => { resolve(await next()) } captured.resolve(undefined) @@ -926,7 +926,7 @@ describe('provider-routed retry policy', () => { }) context = mounted.ctx let downstreamCalls = 0 - context.on('agent/request-error', async (_agent, _context, _signal, next) => { + context.on('agent/request-error', async (_payload, next) => { downstreamCalls += 1 return next() }) @@ -980,7 +980,7 @@ describe('provider-routed retry policy', () => { textResponse('must not run'), ]) ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { - ctx.on('agent/request-error', async (agent, _context, _signal, next) => { + ctx.on('agent/request-error', async ({ agent }, next) => { agent.cancel({ kind: 'user' }) return next() }) diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 036f80647f..c83879c63a 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -33,10 +33,11 @@ const breakdownSchema = z.object({ * * Envelope figures are last-wins per `request/header`; the message figure * rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy - * projection uses — so it equals `measure().surfaceTokens` at every event - * boundary and compaction shrinks it by its logged shadow price, the way it - * shrinks the next request. The state is a fixed handful of numbers, so the - * persisted checkpoint stays O(1) over the session's life. + * projection uses — so fully metered logs equal `measure().surfaceTokens` at + * every event boundary and compaction shrinks the figure by its logged shadow + * price. A replacement without a claim preserves the previous total. The + * state is a fixed handful of numbers, so the persisted checkpoint stays + * O(1) over the session's life. */ export const contextBreakdownProjectionDefinition: ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = { diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index e4dfacc254..2848025b19 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -3,9 +3,10 @@ * surface `measure()` serves and compaction plans against. The projection * units deliberately do NOT share this fold — their state must stay O(1) * for the persisted checkpoint, so they ride `surface-projection.ts`'s - * shadow-price protocol instead. The two stay in agreement by construction: - * both price through `estimate.ts`, and every logged shadow price is derived - * from THIS fold's nodes by the replace producer. + * shadow-price protocol instead. Fully metered logs stay in agreement by + * construction: both price through `estimate.ts`, and every logged shadow + * price is derived from THIS fold's nodes by the replace producer. A + * projection replacement without a claim deliberately folds with zero delta. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts index dcc8181370..9c42d5248e 100644 --- a/packages/llm/token-meter/src/surface-projection.ts +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -10,7 +10,9 @@ * heuristic price of the exact replaced range, so the fold keeps a running * total plus at most one pending claim and never retains per-node prices. * The counts are exact by construction: producers derive them from the same - * fixed estimator this module prices appends with. + * fixed estimator this module prices appends with. A replacement without an + * armed claim folds with zero delta because bounded state cannot reconstruct + * the replaced range; this preserves replay at the cost of possible drift. * * @module @deepseek-ai/dsh-token-meter/surface-projection */ @@ -47,16 +49,19 @@ export interface SurfaceTokensFold { * Fold one committed event onto a running surface-token total. * * A shadow-price event arms a claim; any other event expires it, and a - * surface `replace` must consume a claim naming its exact range — the + * surface `replace` consumes the claim naming its exact range — the * producers append the metering event and the replacement synchronously * adjacent, so a surviving claim always prices the very next event. + * A replace with no claim folds with zero delta because the bounded state + * cannot reconstruct the replaced range. An armed claim for another range + * still fails because the adjacent events contradict each other. * @param claim - the claim armed by the immediately preceding event, if any. * @param event - the next committed session event. * @returns the signed token delta and the claim state after this event. - * @throws when a replacement arrives without a claim for its exact range — - * every in-repo replace producer meters its replacement, so an unpriced - * replacement is a shadow-price contract violation and must fail loud - * rather than let the total drift. + * @throws when a replacement arrives with an armed claim for a different + * range — the metering event was adjacent, so this is a live producer's + * shadow-price contract violation, not historical data, and must fail + * loud rather than let the total drift. */ export function foldSurfaceProjection( claim: ShadowPriceClaim | undefined, @@ -74,10 +79,15 @@ export function foldSurfaceProjection( const tokens = message === null ? 0 : estimateMessage(message) const op = event.surfaceOp if (op === 'append') return { deltaTokens: tokens, claim: undefined } - if (claim === undefined || claim.start !== op.start || claim.end !== op.end) { + // Sessions recorded before the shadow-price protocol log replacements with + // no adjacent metering event; the bounded state cannot reconstruct the + // replaced range's price, so fold those neutrally — historical replay + // degrades to drift instead of failing. + if (claim === undefined) return { deltaTokens: 0, claim: undefined } + if (claim.start !== op.start || claim.end !== op.end) { throw new Error( `token surface: replace at seq ${event.seq} over range ${op.start}-${op.end} has no adjacent shadow price` - + (claim === undefined ? '' : ` (armed claim covers ${claim.start}-${claim.end})`), + + ` (armed claim covers ${claim.start}-${claim.end})`, ) } return { deltaTokens: tokens - claim.tokens, claim: undefined } diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 0d5db509b5..a7fc9debf0 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -155,9 +155,10 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { * `projectedTokens` — the sample plus the surface's signed movement since it * was taken — so occupancy answers for the next request rather than the last * one. The total rides {@link foldSurfaceProjection}, so the state stays O(1) - * and a replacement shrinks it by its logged shadow price. A usage sample is - * stamped BEFORE the same event joins the surface, so an `assistant/message` - * anchors against the surface its own request saw. + * and a replacement shrinks it by its logged shadow price. A replacement + * without a claim preserves the previous total. A usage sample is stamped + * BEFORE the same event joins the surface, so an `assistant/message` anchors + * against the surface its own request saw. */ export const contextPressureProjectionDefinition: ProjectionDefinition<'contextPressure', ContextPressureState> = { diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index b7e4850fd4..20cb2cc819 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -180,7 +180,7 @@ describe('contextBreakdown session projection', () => { expect(agree()).toBeLessThan(grown) }) - it('fails loud on a replacement without an adjacent matching shadow price', () => { + it('folds a replacement without a claim at zero and fails on a mismatched claim', () => { const definition = contextBreakdownProjectionDefinition const replace = (start: number, end: number): SessionEvent => ({ type: 'user/message', @@ -206,15 +206,17 @@ describe('contextBreakdown session projection', () => { let state = definition.init() state = definition.apply(state, append(1)) state = definition.apply(state, append(3)) - // No metering event at all. - expect(() => definition.apply(state, replace(1, 3))).toThrow('no adjacent shadow price') - // A claim for a different range does not price this replacement. + // No metering event: the replacement contributes zero instead of throwing. + expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens) + // An adjacent claim for another range contradicts the replacement. const mismatched = definition.apply(state, meter(1, 1, 8)) expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') - // A claim expires after one intervening event instead of lingering. + // A claim expires after one intervening event, so replacement delta is zero. let expired = definition.apply(state, meter(1, 3, 8)) expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent) - expect(() => definition.apply(expired, replace(1, 3))).toThrow('no adjacent shadow price') + expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens) // The armed claim prices exactly the next event's matching replacement. const armed = definition.apply(state, meter(1, 3, 8)) expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens) diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 07261576eb..0307b96f46 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -419,6 +419,25 @@ describe('contextPressure session projection', () => { expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!) }) + it('folds a replacement without a claim at zero', async () => { + const { ctx, session } = await harness() + const question = appendUser(session, 'a question from an unmetered log') + startStep(session, 1, 1) + usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + const before = pressure(ctx, session) + + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary without a preceding claim' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: question }, + sourceEventSeqs: [question], + }) + + expect(pressure(ctx, session)).toEqual(before) + }) + it('clamps a projection that heuristic error drove below zero', async () => { const { ctx, session } = await harness() recordContext(session, 'large', 128_000) diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 75b8ffd36b..aac726d128 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -202,9 +202,7 @@ export class PlanModeService extends Service { // the session. A failed append remains pending for a later boundary, and // policy cannot block the step. ctx.on('agent/pre-step', async ( - agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 6e614a36a0..34678714fa 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -42,7 +42,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -139,7 +139,7 @@ describe('plan mode through the agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _context, _signal, next) => { + ctx.on('agent/request-error', async ({ agent: subject }, next) => { if (subject !== agent) return next() ctx.planMode.set(agent, true) return { kind: 'retry' } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 87a295e90c..63abed59ea 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -45,7 +45,7 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { acti // Seeded plan state lands before the creation announcement, matching resume. if (active !== undefined) session.append('plan/mode', { active }) // The loop announces creation after publication. - ctx.emit('agent/created', agent) + ctx.emit('agent/created', { agent }) return agent } @@ -74,8 +74,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type: const signal = new AbortController().signal const decision = await events.waterfall( 'agent/pre-step', - [message], - { turn: 1, step: 1, signal }, + { messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [message] }), ) if (decision.kind === 'enter') { diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index c26a65e8ad..804ed0dcb1 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -76,7 +76,7 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/pre-step', async (agent, _messages, _context, next): Promise => { + ctx.on('agent/pre-step', async ({ agent }, next): Promise => { await ctx.sessions.flush(agent.session) return next() }) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index b619871156..dde59610c5 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -228,7 +228,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { ctx.on('session/flush', (current) => { flushed.push(current.id) }) const signal = new AbortController().signal await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) expect(flushed).toEqual([session.id]) diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 634824d6c2..ddc45d18e9 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -163,9 +163,7 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 5d14b7c523..0755e398a0 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -86,8 +86,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn, step, signal }, + { messages: [], turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -105,8 +104,7 @@ async function proposeStep( const signal = new AbortController().signal return await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - messages, - { turn: 1, step: 1, signal }, + { messages, turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages }), ) } @@ -138,8 +136,7 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal }, + { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -241,7 +238,7 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'User-only body.', }) - ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { const decision = await next() if (decision.kind === 'reject') return decision return { diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 04ce4e23f9..acb4e4d36e 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -75,7 +75,7 @@ function prePublicationAbort(): Error { /** Append one one-shot descriptor inside the child's initial turn before its first request. */ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { let appended = false - childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => { + childCtx.on('agent/pre-step', async ({ agent }, next) => { const decision = await next() if (!appended && decision.kind === 'enter') { appended = true diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 389ef5e2a7..33de6d0cc6 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -42,7 +42,7 @@ export async function spawnHarness(workdir: string): Promise { export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 4b038871a1..212536e713 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -287,7 +287,7 @@ export class SubagentContinuationManager { // child-first ordering. const scope = ctx.plugin(function activationOwner() {}) this.ownerCtx = scope.ctx - ctx.on('agent/disposed', (agent) => { + ctx.on('agent/disposed', ({ agent }) => { this.closingScopes.delete(agent) }) ctx.effect(function* (this: SubagentContinuationManager) { @@ -859,12 +859,12 @@ export class SubagentContinuationManager { // quiet Agent from one whose accepted turn has not been admitted yet. // Registered through the child's own scoped context, so scope filtering // already restricts both listeners to this exact agent. - handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => { + handle.agent.ctx.on('agent/inbox/claimed', ({ message }) => { /* v8 ignore next -- a claim of an id this manager never admitted needs * another sender on the same child, which no current path allows. */ if (activation.accepted.delete(message.id)) this.wake(activation) }) - handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => { + handle.agent.ctx.on('agent/inbox/discarded', ({ message }) => { if (activation.accepted.delete(message.id)) this.wake(activation) }) // Agent creation committed setup at its publication boundary; diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 7b7a2ab541..dca06add38 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -159,10 +159,10 @@ describe('SubagentService.startContinuable', () => { it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => { const { ctx, parent, adapter } = await setup([textResponse('first answer')]) const enqueued: { id: MessageId; loggedYet: boolean }[] = [] - ctx.on('agent/inbox/inserted', (agent, accepted) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { // Acceptance is the boundary `startContinuable` resolves at, so observe // the log state exactly there rather than after later microtasks. - enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -231,7 +231,7 @@ describe('SubagentService.startContinuable', () => { const { ctx, parent } = await setup([textResponse('unused')]) const controller = new AbortController() // Abort inside the child's creation window: setup runs before publication. - ctx.on('agent/created', (child) => { + ctx.on('agent/created', ({ agent: child }) => { if (child !== parent) controller.abort('caller gave up') }) @@ -753,7 +753,7 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) const disposals: SessionId[] = [] - ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) + ctx.on('agent/disposed', ({ agent }) => { disposals.push(agent.id) }) const drained = drainManager(ctx) // Let the held model call observe its cancellation so quiescence can settle. hold.resolve(undefined) @@ -984,7 +984,7 @@ describe('continuable durability and teardown', () => { const drains: Promise[] = [] const accepted: MessageId[] = [] ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) - ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) }) + ctx.on('agent/inbox/inserted', ({ message }) => { accepted.push(message.id) }) await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -998,12 +998,12 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setup([]) const order: string[] = [] const drains: Promise[] = [] - ctx.on('agent/created', (child) => { + ctx.on('agent/created', ({ agent: child }) => { if (child === parent) return const draining = drainManager(ctx).then(() => { order.push('drain') }) drains.push(draining) }) - ctx.on('agent/disposed', (child) => { + ctx.on('agent/disposed', ({ agent: child }) => { if (child !== parent) order.push('disposed') }) @@ -1025,8 +1025,8 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const order: string[] = [] - child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) { + child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'before drain')) { order.push('enqueue') } }) @@ -1208,7 +1208,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block the resumed prompt so this epoch produces nothing of its own. - ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } }) @@ -1356,8 +1356,8 @@ describe('continuable review regressions', () => { // Cancel from the synchronous enqueue observer: the discard fires after the // id is recorded but before `followup()` returns. - const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) @@ -1388,8 +1388,8 @@ describe('continuable review regressions', () => { await followup(ctx, parent, started.childId, message('queued')) expect(activation.accepted.size).toBe(1) - const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) @@ -1406,7 +1406,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block admission so the child's only turn never opens. - ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } }) @@ -1428,7 +1428,7 @@ describe('continuable review regressions', () => { const registeredAtEnqueue: boolean[] = [] // A synchronous inbox observer runs before the admitting microtask, the // exact window where `Agent.status` is still idle. - ctx.on('agent/inbox/inserted', (agent) => { + ctx.on('agent/inbox/inserted', ({ agent }) => { if (agent.session.header.parentSession !== undefined) { registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent) } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 64c29d5122..ac90b4612b 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -164,9 +164,9 @@ describe('dsh-tool-subagent-report', () => { const { started, child } = await startChild(ctx, parent) const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', (agent, item) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') } }) @@ -190,9 +190,9 @@ describe('dsh-tool-subagent-report', () => { const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } }) const { child } = await startChild(ctx, parent) const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', (agent, item) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') } }) diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 0bebbcc561..5cc17ddb79 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -89,7 +89,7 @@ export class TelemetryCoordinator { this.hintFlush(session) }) }) - ctx.on('agent/error', (agent, turn, step, error) => { + ctx.on('agent/error', ({ agent, turn, step, error }) => { this.contain(() => { this.relayAgentError(agent, turn, step, error) }) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 02ca434c0d..8bdf71ff7b 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -427,7 +427,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => { const session = liveSession(ctx, 'erring') // Only the members the relay reads; the full Agent surface is irrelevant here. const agent = { id: 'agent-1', session } as Agent - ctx.emit('agent/error', agent, 3, 2, error) + ctx.emit('agent/error', { agent, turn: 3, step: 2, error }) const record = backend.records.find(r => r.channel === 'ops')! expect(record.severity).toBe('error') expect(record.attributes).toMatchObject({ diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index aff2958de3..f8be1ec27f 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -25,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index e0e04a4fa1..005b8e2157 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -686,7 +686,9 @@ class FaceAnalyzer { const records: ExportRecord[] = [] for (const [subpath, target] of targets) { if (target.includes('*') || subpath === './package.json' - || subpath === './typert' || subpath === './client/typert' || target.endsWith('.json')) continue + || subpath === './typert' || subpath === './client/typert' + // Data exports (bundle patch lists, JSON manifests) carry no TypeScript API. + || target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue const sourcePath = sourcePathForExport(registration.root, target) const sourceFile = this.sourceFiles.get(realPath(sourcePath)) if (sourceFile === undefined) { diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 09d621691f..398ec6e923 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: fbdd4c1332a1cc52f15a8ce28264ea16d47fc552 -README.zh.md: b67fb126ea477acf2e79f5bc1d695a5fc9ca8c82 +README.md: cdd78047b6ad71148c6ebeba598b63b4ae4cfa7b +README.zh.md: ee2b07884e68510e2b59b9f2c27053c263d15f1a diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index fbdd4c1332..cdd78047b6 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -12,10 +12,11 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | -| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR | -| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer | +| `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | +| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by user patch-layer HMR | +| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | +| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | @@ -29,14 +30,16 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. -## Personal config +## Profiles -A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's Web and headless modes ([`apps/cli`](../../../apps/cli/README.md)); raw config mode and the demo bins boot their named trees without this layer. Two optional files: +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). + +User-level machine-local preferences also live in the Harness home: - **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the Web settings page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. -- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. +- **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. -Web keeps `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. +Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. ## Model Experience @@ -51,4 +54,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. -- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. +- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b67fb126ea..ee2b07884e 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -12,10 +12,11 @@ | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | -| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 | -| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer | +| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | +| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer | +| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | @@ -29,14 +30,16 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 -## 个人配置 +## Profile -开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 Web 与 headless 模式([`apps/cli`](../../../apps/cli/README.md))使用;原始配置模式与 demo bin 会在不加该层的情况下启动指定的配置树。这里有两个可选文件: +profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。 + +用户级的机器本地偏好同样位于 Harness home 中: - **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 -- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 +- **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 -Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 +长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 ## 模型体验 @@ -51,4 +54,4 @@ Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 -- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 +- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 2e5a133f00..e14b249f5c 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,19 +1,19 @@ /** * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the - * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to + * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot */ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' -import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomePath } from '@deepseek-ai/dsh-paths' import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -25,6 +25,27 @@ declare module 'cordis' { } } +export { + composeEntries, + DEFAULT_PROFILE_BUNDLES, + healProfilesModuleFallback, + initProfile, + loadProfile, + PROFILE_PATCH_FILENAME, + PROFILE_TEMPLATES, + PROFILES_DIR, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + writeProfileManifest, + type DshBundleManifest, + type DshManifestSection, + type DshProfileManifest, + type Profile, + type ProfileLayer, + type ProfileManifest, +} from './profile.ts' + /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. @@ -65,49 +86,100 @@ export function loadEnv( } } -/** File inside the Harness home holding the personal loader overlay patches. */ -export const PERSONAL_CONFIG_FILENAME = 'config.yaml' - const bootstrapIncludes = new WeakMap() // The include's YAML dialect (`!!js` scalars become expression nodes the // Loader interpolates against each entry's context at mount time), imported // from the include itself so patch parsing and config dumping can never drift -// from what the include mounts. Personal patches share it so they may +// from what the include mounts. User patch layers share it so they may // reference `process.env`. -const personalPatchesSchema = entryListSchema +const userPatchesSchema = entryListSchema + +/** Options for live user patch-layer reconciliation. */ +export interface UserPatchWatchOptions { + /** Diagnostic prefix used by {@link loadOptionalPatches}. */ + binName: string + /** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */ + filename: string + /** + * Compose the full patch list for a fresh user-layer generation — + * the same composition the app booted with, so a reload can interleave the + * new user patches between app-owned layers (bundle layers below, + * overlay/flag patches above). Identity when omitted: the user layer + * is the whole patch list. + */ + compose?: (userPatches: PatchOptions[]) => PatchOptions[] +} /** - * Load the optional personal overlay patches (`config.yaml` under the Harness - * home). The file is a top-level YAML array of loader patch entries - * (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides - * and `insert` lists, with `!!js` expressions allowed. A missing file means - * "no personal overlay"; an unreadable, unparsable, or non-array file throws — - * a present personal config that cannot apply is a misconfiguration and must - * fail loud at boot, never be silently skipped. + * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include. + * @param ctx - settled app context containing the root Include and an active HMR service. + * @param options - diagnostic, file, and patch-composition inputs. + * @returns an asynchronous disposer after the exact-path watcher is ready. + * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. + */ +export async function watchUserPatches( + ctx: Context, + options: UserPatchWatchOptions, +): Promise<() => Promise> { + const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options + const hmr = ctx.get('hmr') + if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`) + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`) + const register = hmr.registerConfig(filename, async () => { + // Re-read the include's non-patch options per refresh: a writer that + // updates the root Include's other options between refreshes (none exists + // today) must not have them silently reverted by a user-layer reload. + const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config + const userPatches = loadOptionalPatches(binName, filename) ?? [] + const patches = compose(userPatches) + await entry.update({ + config: { + ...includeConfig, + patches, + }, + }) + }) + try { + return await register + } catch (error) { + // A surface can dispose the whole tree while the watcher is still opening; + // the HMR effect registration then fails with INACTIVE_EFFECT. That is the + // app exiting exactly as asked, not a watch failure, so return a no-op + // disposer instead of crashing. + if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} + throw error + } +} + +/** + * Load an optional patch-list file: a top-level YAML array of loader patch + * entries (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config + * overrides and `insert` lists, with `!!js` expressions allowed. A missing + * file means "no layer"; an unreadable, unparsable, or non-array file throws — + * a present patch file that cannot apply is a misconfiguration and must fail + * loud at boot, never be silently skipped. * @param binName - the diagnostic prefix on the thrown error. - * @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`). + * @param file - absolute path of the patch file. * @returns the parsed patches, or `undefined` when the file does not exist. */ -export function loadPersonalPatches( - binName: string, dir: string = resolveDshHome(), -): PatchOptions[] | undefined { - const file = join(dir, PERSONAL_CONFIG_FILENAME) +export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined { let content: string try { content = readFileSync(file, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined - throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`) + throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`) } - return parsePatchList(binName, file, content, 'personal patches') + return parsePatchList(binName, file, content, 'patches') } /** - * Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a - * `--config ` overlay applied over the shared base. Same file format as - * {@link loadPersonalPatches}, but a missing file throws, because the caller - * named this file — its absence is a misconfiguration, not "no overlay". + * Load a required overlay patch list: a bundle's `cordis.patch.yml` or a + * `--patch ` overlay. Same file format as {@link loadOptionalPatches}, + * but a missing file throws, because the caller named this file — its absence + * is a misconfiguration, not "no overlay". * @param binName - the diagnostic prefix on the thrown error. * @param file - absolute path of the overlay file. * @returns the parsed patch list. @@ -121,7 +193,6 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } return parsePatchList(binName, file, content, 'overlay') } - /** * Parse one loader patch list: a top-level YAML array of * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and @@ -132,7 +203,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ * @param binName - the diagnostic prefix on the thrown error. * @param file - the source path, quoted in errors. * @param content - the file's text. - * @param label - what to call this list in errors (`personal patches`, `overlay`). + * @param label - what to call this list in errors (`patches`, `overlay`). * @returns the parsed patch list. */ function parsePatchList( @@ -140,7 +211,7 @@ function parsePatchList( ): PatchOptions[] { let parsed: unknown try { - parsed = yaml.load(content, { schema: personalPatchesSchema }) + parsed = yaml.load(content, { schema: userPatchesSchema }) } catch (error) { throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`) } @@ -159,7 +230,7 @@ function parsePatchList( export interface ConfigDumpLayer { /** Source name shown in provenance comments (a file basename or path). */ label: string - /** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */ + /** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */ patches: PatchOptions[] } @@ -290,70 +361,11 @@ function groupedDump( return lines.join('\n') + '\n' } -/** Options for live personal-config reconciliation. */ -export interface PersonalPatchWatchOptions { - /** Diagnostic prefix used by {@link loadPersonalPatches}. */ - binName: string - /** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */ - dir?: string - /** - * Compose the full patch list for a fresh personal-overlay generation — - * the same composition the app booted with, so a reload can interleave the - * new personal patches between app-owned layers (surface overlay below, - * profile/flag patches above). Identity when omitted: the personal overlay - * is the whole patch list. - */ - compose?: (personalPatches: PatchOptions[]) => PatchOptions[] -} - /** - * Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include. - * @param ctx - settled app context containing the root Include and an active HMR service. - * @param options - diagnostic, Harness-home, and patch-composition inputs. - * @returns an asynchronous disposer after the exact-path watcher is ready. - * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. - */ -export async function watchPersonalPatches( - ctx: Context, - options: PersonalPatchWatchOptions, -): Promise<() => Promise> { - const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options - const hmr = ctx.get('hmr') - if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`) - const entry = bootstrapIncludes.get(ctx) - if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) - const filename = join(dir, PERSONAL_CONFIG_FILENAME) - const register = hmr.registerConfig(filename, async () => { - // Re-read the include's non-patch options per refresh: a writer that - // updates the root Include's other options between refreshes (none exists - // today) must not have them silently reverted by a personal reload. - const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config - const personalPatches = loadPersonalPatches(binName, dir) ?? [] - const patches = compose(personalPatches) - await entry.update({ - config: { - ...includeConfig, - patches, - }, - }) - }) - try { - return await register - } catch (error) { - // A surface can dispose the whole tree while the watcher is still opening; - // the HMR effect registration then fails with INACTIVE_EFFECT. That is the - // app exiting exactly as asked, not a watch failure, so return a no-op - // disposer instead of crashing. - if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} - throw error - } -} - -/** - * Mount and remember the exact root Include entry used by app boot and personal-config HMR. + * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR. * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. - * @param patches - initial app and personal patches, applied in order. + * @param patches - initial app and user patches, applied in order. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the * transactional create was still settling entry lifecycle. @@ -599,7 +611,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree - * (see {@link loadPersonalPatches}); an empty list mounts none. + * (see {@link loadOptionalPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. diff --git a/packages/ui/app-boot/src/profile.ts b/packages/ui/app-boot/src/profile.ts new file mode 100644 index 0000000000..c353f18bef --- /dev/null +++ b/packages/ui/app-boot/src/profile.ts @@ -0,0 +1,388 @@ +/** + * Profile discovery, initialization, and patch-layer composition for the + * `dsh --profile` launcher family. + * + * A profile is a directory under `$DSH_HOME/profiles/` holding a + * `package.json` (out-of-tree plugin dependencies plus the profile manifest + * `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` + * (the user's own patch layer, applied after every bundle layer). Bundles are + * npm packages whose manifest declares + * `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the tree is + * composed by applying each bundle's patch list in `dsh.profile.bundles` order over + * an empty entry list, then the profile's own patches, then any launcher + * layers (`--patch` files and flag-derived patches). + * + * Module resolution is two-anchor by construction: a bundle name resolves + * first from the dsh installation (the launcher's own package), then from the + * profile directory. The Loader's `baseUrl` is the profile directory, whose + * `node_modules` pnpm manages for out-of-tree plugins, while the maintained + * flat fallback directory `$DSH_HOME/profiles/node_modules` (one symlink per + * package the installation's app and bundles depend on) makes every in-box + * plugin Node-resolvable from any profile through the ordinary parent-walk. + * @module @deepseek-ai/dsh-app-boot/profile + */ + +import { createRequire } from 'node:module' +import { + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { applyEntryPatches, type PatchOptions } from '@cordisjs/plugin-include' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { loadOverlayPatches } from './index.ts' + +/** Directory under the Harness home holding every profile. */ +export const PROFILES_DIR = 'profiles' + +/** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */ +export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' + +/** The bundle half of the `dsh` manifest section: what a bundle package exports. */ +export interface DshBundleManifest { + /** The patch layer this bundle exports, relative to its package root. */ + patch: string +} + +/** The profile half of the `dsh` manifest section: what a profile directory composes. */ +export interface DshProfileManifest { + /** Ordered bundle layer list (package names). */ + bundles?: string[] +} + +/** + * The `dsh`-owned manifest section of a package.json. The nested key names + * the manifest kind: a bundle package declares `bundle`, a profile directory + * declares `profile`; nothing declares both. + */ +export interface DshManifestSection { + /** Present on bundle packages only. */ + bundle?: DshBundleManifest + /** Present on profile manifests only. */ + profile?: DshProfileManifest +} + +/** The slice of package.json both profiles and bundles use. */ +export interface ProfileManifest { + name?: string + dependencies?: Record + peerDependencies?: Record + dsh?: DshManifestSection +} + +/** One resolved bundle layer of a profile. */ +export interface ProfileLayer { + /** The bundle's package name, as listed in `dsh.profile.bundles`. */ + packageName: string + /** Absolute directory of the resolved bundle package. */ + packageDir: string + /** Absolute path of the bundle's patch file. */ + patchPath: string + /** The parsed patch list. */ + patches: PatchOptions[] +} + +/** A loaded profile: resolved bundle layers plus the user's own patch layer. */ +export interface Profile { + /** The profile name (its directory basename). */ + name: string + /** Absolute profile directory. */ + dir: string + /** Bundle layers in `dsh.profile.bundles` order. */ + layers: ProfileLayer[] + /** Absolute path of the profile's own patch file. */ + patchPath: string + /** The profile's own patches; empty when the file is absent. */ + patches: PatchOptions[] +} + +/** + * Resolve a profile's directory under the Harness home. + * @param name - the profile name (`dsh --profile `). + * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @returns the absolute profile directory (which may not exist yet). + */ +export function resolveProfileDir(name: string, home: string = resolveDshHome()): string { + if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..' + // The launcher-maintained flat module fallback lives at this sibling path. + || name === 'node_modules') { + throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`) + } + return join(home, PROFILES_DIR, name) +} + +/** The shipped profile templates auto-initialized on first use, by name. */ +export const PROFILE_TEMPLATES: Record = { + web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], + headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'], +} + +/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */ +export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base'] + +const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: +# a top-level YAML array of loader patch entries (id-targeted config +# overrides, disables, and insert lists; \`!!js\` expressions allowed). +[] +` + +// The hoisted linker gives out-of-tree plugins a flat node_modules whose +// missing peers (cordis and friends) fall through to the healed +// profiles/node_modules installation fallback, so every plugin shares the +// installation's single cordis instance instead of a duplicate. pnpm ≥10 +// reads its settings from pnpm-workspace.yaml, not .npmrc. +const PROFILE_PNPM_WORKSPACE = `packages: + - . + +nodeLinker: hoisted +autoInstallPeers: false +` + +/** + * Initialize a profile directory: manifest, empty user patch layer, and the + * pnpm settings out-of-tree plugins need. Existing files are never touched, + * so re-running is a no-op on an initialized profile. + * @param dir - the profile directory from {@link resolveProfileDir}. + * @param bundles - the initial `dsh.profile.bundles` layer list. + */ +export function initProfile(dir: string, bundles: readonly string[]): void { + mkdirSync(dir, { recursive: true }) + const manifestPath = join(dir, 'package.json') + if (!existsSync(manifestPath)) { + const manifest: ProfileManifest & { private: boolean } = { + name: `dsh-profile-${basename(dir)}`, + private: true, + dependencies: {}, + dsh: { profile: { bundles: [...bundles] } }, + } + writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n') + } + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE) + const workspacePath = join(dir, 'pnpm-workspace.yaml') + if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE) +} + +/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */ +function ensureSymlink(link: string, target: string): void { + let stat + try { + stat = lstatSync(link) + } catch { + // Missing link (first run) — created below. Any other lstat failure on a + // path we just created the parent of would resurface on symlinkSync. + stat = undefined + } + if (stat !== undefined) { + if (!stat.isSymbolicLink()) { + throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) + } + if (readlinkSync(link) === target) return + rmSync(link) + } + try { + symlinkSync(target, link, 'junction') + } catch (error) { + // Concurrent launches heal the same fallback; losing the race to a + // process writing the identical link is success, anything else is not. + // The window between the lstat miss above and this write cannot be + // staged deterministically from the public surface. + /* v8 ignore next 4 */ + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' + || !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) { + throw error + } + } +} + +/** + * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one + * symlink per package in the dsh app's resolvable dependency CLOSURE (BFS + * over `dependencies` from the app manifest), each resolved from its own + * real location. Node's parent-directory walk from any profile finds this + * directory after the profile's own `node_modules`, so every in-box plugin + * resolves without pnpm ever managing it — the exact "bundles come from the + * installation" contract. The closure (not just direct dependencies) is + * required for out-of-tree plugins: their peer dependencies name seam + * packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only + * through its implementation packages. Symlinked packages resolve their own + * dependencies from their real directories (Node's default + * symlink-following), so each package needs only its one flat link. + * Idempotent: correct links are kept and moved installations are + * re-pointed; a stale link to a vanished package stays until its name is + * reused (dangling links are invisible to resolution). + * @param installAnchor - absolute path of the dsh app's package.json. + * @param home - the Harness home; defaults to {@link resolveDshHome}. + */ +export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void { + const profilesDir = join(home, PROFILES_DIR) + const modulesDir = join(profilesDir, 'node_modules') + mkdirSync(modulesDir, { recursive: true }) + const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest + const links = new Map() + /* v8 ignore next -- a real app manifest always declares its name */ + if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor)) + // BFS over the resolvable dependency graph; the visited set is the link + // map itself (first resolution wins, matching Node's own nearest-wins). + const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }] + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + // Peer dependencies participate: seam packages (dsh-subprocess, + // dsh-compact, ...) are peers of their implementations, never plain + // dependencies, yet out-of-tree plugins import them directly. + /* v8 ignore next -- a real app manifest always declares dependencies */ + for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) { + if (links.has(dep)) continue + const dir = packageDirFromAnchor(next.anchor, dep) + // A declared-but-uninstalled dependency cannot be a loader-visible + // plugin; skip it rather than fail the whole boot. + if (dir === undefined) continue + links.set(dep, dir) + const manifestPath = join(dir, 'package.json') + queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) + } + } + for (const [packageName, target] of links) { + const link = join(modulesDir, packageName) + mkdirSync(dirname(link), { recursive: true }) + ensureSymlink(link, target) + } +} + +/** + * Read a profile's manifest. + * @param binName - the diagnostic prefix on the thrown error. + * @param dir - the profile directory. + * @returns the parsed manifest. + */ +export function readProfileManifest(binName: string, dir: string): ProfileManifest { + const path = join(dir, 'package.json') + let raw: string + try { + raw = readFileSync(path, 'utf8') + } catch (error) { + throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`) + } + // File boundary: the shape check below validates what the parse type asserts. + const parsed = JSON.parse(raw) as ProfileManifest | null + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`) + } + return parsed +} + +/** + * Write a profile's manifest back (2-space JSON, trailing newline). + * @param dir - the profile directory. + * @param manifest - the manifest value to persist. + */ +export function writeProfileManifest(dir: string, manifest: ProfileManifest): void { + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') +} + +/** + * Resolve a package's root directory from one anchor without depending on the + * package exporting `./package.json` (`require.resolve` would need that): + * probe the require resolution paths for a directory holding the named + * manifest. This is Node's own node_modules lookup order, so the result + * matches what the Loader would import from the same anchor, and + * `existsSync` follows the symlinks pnpm's isolated layout uses. + */ +function packageDirFromAnchor(anchor: string, packageName: string): string | undefined { + // resolve.paths returns null only for builtins, which no bundle name is. + /* v8 ignore next */ + for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) { + const candidate = join(searchPath, packageName) + if (existsSync(join(candidate, 'package.json'))) return candidate + } + return undefined +} + +/** + * Resolve one bundle package's directory: installation anchor first, then the + * profile directory. The installation-first order is the contract that + * `@deepseek-ai/dsh-base` (and every other in-box bundle) always comes from + * the same installation as the running dsh, never from a profile-local copy. + * Resolution does not require the package to export `./package.json`. + * @param binName - the diagnostic prefix on the thrown error. + * @param packageName - the bundle's package name from `dsh.profile.bundles`. + * @param installAnchor - absolute path of a file inside the dsh app package (its package.json). + * @param profileDir - the profile directory (second anchor). + * @returns the bundle package's absolute directory. + */ +export function resolveBundleDir( + binName: string, packageName: string, installAnchor: string, profileDir: string, +): string { + for (const anchor of [installAnchor, join(profileDir, 'package.json')]) { + const dir = packageDirFromAnchor(anchor, packageName) + if (dir !== undefined) return dir + } + throw new Error( + `${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; ` + + `run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`, + ) +} + +/** + * Load a profile: resolve every `dsh.profile.bundles` entry to its patch + * layer and parse the profile's own patch file. A listed bundle without a + * `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer + * is a misconfiguration, not "no patches". + * @param binName - the diagnostic prefix on thrown errors. + * @param name - the profile name. + * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor). + * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a + * bundles-only consumer (`--dump-default-config`, a recovery diagnostic) + * cannot fail on a broken user layer. + * @returns the loaded profile (empty `patches` when the user layer is skipped). + */ +export function loadProfile( + binName: string, name: string, installAnchor: string, home: string = resolveDshHome(), + options: { userLayer?: boolean } = {}, +): Profile { + const dir = resolveProfileDir(name, home) + if (!existsSync(join(dir, 'package.json'))) { + const template = PROFILE_TEMPLATES[name] + if (template === undefined) { + throw new Error( + `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add '`, + ) + } + initProfile(dir, template) + } + const manifest = readProfileManifest(binName, dir) + // A hand-written profile manifest may omit the dsh section entirely. + const bundles = manifest.dsh?.profile?.bundles ?? [] + const layers = bundles.map((packageName): ProfileLayer => { + const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) + const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest + const declared = bundleManifest.dsh?.bundle?.patch + if (declared === undefined) { + throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) + } + const patchPath = join(packageDir, declared) + return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + }) + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + const patches = options.userLayer !== false && existsSync(patchPath) + ? loadOverlayPatches(binName, patchPath) + : [] + return { name, dir, layers, patchPath, patches } +} + +/** + * Compose patch layers into the effective entry list over an empty root — + * the same single `applyEntryPatches` call the boot include makes, so flag + * derivation and config dumps see exactly what mounts. + * @param layers - patch lists in application order. + * @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them). + * @returns the composed entry list. + */ +export function composeEntries( + layers: readonly PatchOptions[][], warn: (message: string) => void = () => {}, +): EntryOptions[] { + return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => { + let index = 0 + warn(message.replace(/%C/g, () => JSON.stringify(args[index++]))) + }) +} diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 1e88954f69..45eab9ea7d 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -341,11 +341,11 @@ describe('include refresh with overlay patches', () => { describe('include patches layered over one base', () => { it('lets a later patch configure or disable a row an earlier patch inserted', async () => { - // The surface/`--config`/personal composition: `dsh` includes one shared - // base and applies each source as its own patch list at the SAME include + // The bundle/user-layer/`--patch` composition: `dsh` includes one root + // and applies each source as its own patch list at the SAME include // level, because patches never cross an include boundary. A later layer // must therefore be able to reach a row an earlier layer inserted, or - // surface-only rows would be invisible to the user's personal config. + // bundle-only rows would be invisible to the user's patch layer. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') @@ -355,30 +355,30 @@ describe('include patches layered over one base', () => { ' config:', ' path: ./base.yml', ' patches:', - // Layer 1 (a surface overlay): patch a base row and add two of its own. + // Layer 1 (a bundle layer): patch a base row and add two of its own. ' - id: shared', ' config:', - ' value: surface', + ' value: bundle', ' - insert:', - ' - id: surface-kept', + ' - id: bundle-kept', ' name: ./noop.mjs', ' config:', - ' value: surface-default', - ' - id: surface-dropped', + ' value: bundle-default', + ' - id: bundle-dropped', ' name: ./noop.mjs', // Layer 2 (the user): reconfigure one inserted row and disable the other. - ' - id: surface-kept', + ' - id: bundle-kept', ' config:', - ' value: personal', - ' - id: surface-dropped', + ' value: user', + ' - id: bundle-dropped', ' disabled: true', '', ].join('\n')) const ctx = await boot(NAME, join(dir, 'cordis.yml')) try { - expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' }) - expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' }) - const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped') + expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' }) + expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' }) + const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped') expect(dropped?.options.disabled).toBe(true) expect(dropped?.fiber).toBeUndefined() } finally { diff --git a/packages/ui/app-boot/tests/profile.spec.ts b/packages/ui/app-boot/tests/profile.spec.ts new file mode 100644 index 0000000000..f0bd6f5da7 --- /dev/null +++ b/packages/ui/app-boot/tests/profile.spec.ts @@ -0,0 +1,245 @@ +/** + * Profile machinery of `dsh-app-boot`: directory resolution and init, + * manifest round-trips, two-anchor bundle resolution, patch-layer loading, + * empty-root composition, and the installation module-fallback healing. + */ + +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + composeEntries, + healProfilesModuleFallback, + initProfile, + loadProfile, + PROFILE_PATCH_FILENAME, + PROFILE_TEMPLATES, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + writeProfileManifest, +} from '../src/index.ts' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-')) + +/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */ +function stageInstallation(bundles: Record }>): string { + const root = tmp() + const appDir = join(root, 'app') + mkdirSync(join(appDir, 'node_modules'), { recursive: true }) + const appDeps: Record = {} + for (const [name, spec] of Object.entries(bundles)) { + appDeps[name] = '0.0.0' + const dir = join(appDir, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name, + version: '0.0.0', + dependencies: spec.deps ?? {}, + ...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } }, + })) + if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch) + } + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', dependencies: appDeps })) + return join(appDir, 'package.json') +} + +describe('resolveProfileDir', () => { + it('joins the home and rejects traversal-shaped names', () => { + const home = tmp() + expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui')) + for (const bad of ['', '.', '..', 'a/b', 'a\\b']) { + expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name') + } + }) +}) + +describe('initProfile', () => { + it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => { + const home = tmp() + const dir = resolveProfileDir('tui', home) + initProfile(dir, ['@deepseek-ai/dsh-base']) + const manifest = readProfileManifest('t', dir) + expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]') + expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted') + // Re-init keeps user edits. + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n') + initProfile(dir, ['other']) + expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x') + }) +}) + +describe('manifest round-trip', () => { + it('writes and reads back, and fails loud on a broken manifest', () => { + const dir = tmp() + writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } }) + expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a']) + writeFileSync(join(dir, 'package.json'), '[]') + expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object') + expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest') + }) +}) + +describe('resolveBundleDir', () => { + it('prefers the installation anchor, falls back to the profile, and fails loud', () => { + const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } }) + const profileDir = tmp() + mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), '{}') + writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' })) + expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box') + expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only') + expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle') + }) + + it('resolves a package whose exports map omits ./package.json', () => { + // Common on npm: an exports map without "./package.json" makes + // require.resolve('/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED; + // resolution must fall through to the paths probe instead of misreporting + // the installed package as missing. + const anchor = stageInstallation({}) + const profileDir = tmp() + writeFileSync(join(profileDir, 'package.json'), '{}') + const dir = join(profileDir, 'node_modules', 'sealed-bundle') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'sealed-bundle', + version: '0.0.0', + exports: { '.': './index.js' }, + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(dir, 'index.js'), '') + writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n') + expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir) + }) +}) + +describe('loadProfile', () => { + it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' }, + 'bundle-b': { patch: '- id: a\n config:\n v: 2\n' }, + }) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, ['bundle-a', 'bundle-b']) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n') + const profile = loadProfile('t', 'demo', anchor, home) + expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b']) + expect(profile.patches).toHaveLength(1) + const entries = composeEntries([ + ...profile.layers.map(layer => layer.patches), + profile.patches, + ]) + expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }]) + // A hand-made profile without the user layer file or dsh section: empty layers, no throw. + rmSync(join(dir, PROFILE_PATCH_FILENAME)) + expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([]) + writeProfileManifest(dir, { name: 'bare' }) + const bare = loadProfile('t', 'demo', anchor, home) + expect(bare.layers).toEqual([]) + }) + + it('auto-initializes only shipped templates and fails loud otherwise', () => { + const anchor = stageInstallation({}) + const home = tmp() + expect(() => loadProfile('t', 'custom', anchor, home)) + .toThrow('profile "custom" does not exist') + // The web template auto-initializes on first load. Bundle resolution + // cannot be asserted to fail here: the source-plane test runner resolves + // @deepseek-ai/* through tsconfig paths regardless of the staged anchor. + expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base') + try { + loadProfile('t', 'web', anchor, home) + } catch { + // Resolution failure is the plain-Node outcome for this empty anchor. + } + expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles) + .toEqual([...PROFILE_TEMPLATES.web ?? []]) + }) + + it('fails loud when a listed bundle declares no dsh.bundle', () => { + const anchor = stageInstallation({ 'not-a-bundle': {} }) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, ['not-a-bundle']) + expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle') + }) +}) + +describe('composeEntries', () => { + it('applies layers over an empty root and reports skipped patches', () => { + const warnings: string[] = [] + const entries = composeEntries([ + [{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }], + [{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }], + ], message => warnings.push(message)) + expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }]) + expect(warnings.join('\n')).toContain('"missing"') + // Default warn sink: skipped patches are silently dropped (boot repeats them). + expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([]) + }) +}) + +describe('healProfilesModuleFallback', () => { + it('links the app and bundle dependency surface flat under profiles/node_modules', () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } }, + 'plain-lib': {}, + }) + // An app dependency that is declared but not installed: skipped, not fatal. + const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record } + appManifest.dependencies['never-installed'] = '0.0.0' + writeFileSync(anchor, JSON.stringify(appManifest)) + // dep-of-a lives in the installation's node_modules too. + const modules = join(anchor, '..', 'node_modules') + mkdirSync(join(modules, 'dep-of-a'), { recursive: true }) + writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' })) + const home = tmp() + healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + // App deps, the bundle's own deps, and the bundle itself are linked; the + // plain library is linked as an app dep (harmless), the app itself too. + for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) { + expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true) + } + // Idempotent, and a moved target is re-pointed. + healProfilesModuleFallback(anchor, home) + const before = readlinkSync(join(fallback, 'dep-of-a')) + expect(before).toContain('dep-of-a') + }) + + it('throws when a fallback entry is a real directory', () => { + const anchor = stageInstallation({}) + const home = tmp() + mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true }) + expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink') + }) + + it('replaces a wrong symlink', () => { + const anchor = stageInstallation({}) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules') + mkdirSync(fallback, { recursive: true }) + symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction') + healProfilesModuleFallback(anchor, home) + expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') + }) + + it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => { + // The EEXIST arm: a second process wrote the link between our lstat miss + // and symlinkSync. Simulated by pre-creating the correct link and calling + // the internal path through a stale-lstat shim is not possible from + // outside, so probe the observable contract: healing twice concurrently + // is a no-op, and a foreign REAL directory still fails loud. + const anchor = stageInstallation({}) + const home = tmp() + healProfilesModuleFallback(anchor, home) + healProfilesModuleFallback(anchor, home) // second healer sees the correct link + const fallback = join(home, 'profiles', 'node_modules') + expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) + }) +}) diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/user-patches.spec.ts similarity index 63% rename from packages/ui/app-boot/tests/personal-config.spec.ts rename to packages/ui/app-boot/tests/user-patches.spec.ts index 7c92d53e56..333385ee50 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/user-patches.spec.ts @@ -1,7 +1,7 @@ /** - * Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`) - * `config.yaml` overlay loader and `boot()` applying the personal overlay over - * a real Loader tree. + * User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader + * (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over + * a real Loader tree, kept live through transactional HMR. */ import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' @@ -15,14 +15,14 @@ import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' import { boot, - loadPersonalPatches, - PERSONAL_CONFIG_FILENAME, - watchPersonalPatches, + loadOptionalPatches, + PROFILE_PATCH_FILENAME, + watchUserPatches, } from '../src/index.ts' const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-')) async function eventually(test: () => boolean, message: string): Promise { const deadline = Date.now() + 10_000 @@ -34,20 +34,20 @@ async function eventually(test: () => boolean, message: string): Promise { const settleChokidarChangeThrottle = (): Promise => new Promise(resolve => setTimeout(resolve, 75)) -describe('loadPersonalPatches', () => { +describe('loadOptionalPatches', () => { afterEach(() => { delete process.env.DSH_HOME }) - it('returns undefined when no personal patches file exists', () => { - expect(loadPersonalPatches(NAME, tmp())).toBeUndefined() + it('returns undefined when no user patch file exists', () => { + expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined() }) it('parses a patch list and preserves !!js expressions as loader expression nodes', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [ + '- id: agent-loop', + " name: '@deepseek-ai/dsh-agent-loop'", ' config:', ' model: !!js process.env.DSH_SPEC_MODEL', '- insert:', @@ -55,51 +55,44 @@ describe('loadPersonalPatches', () => { " name: '@deepseek-ai/dsh-llm-pi-ai'", '', ].join('\n')) - const patches = loadPersonalPatches(NAME, dir) + const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)) expect(patches).toHaveLength(2) expect(patches?.[0]).toMatchObject({ - id: 'tui-agent', + id: 'agent-loop', config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } }, }) expect(patches?.[1]?.insert).toHaveLength(1) }) - it('defaults its directory to the Harness home ($DSH_HOME)', () => { + it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n') - process.env.DSH_HOME = dir - expect(loadPersonalPatches(NAME)).toHaveLength(1) - }) - - it('fails loud on an unreadable file (a present personal config is never skipped)', () => { - const dir = tmp() - mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to read personal patches `)) + mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to read patches `)) }) it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'invalid: [unclosed\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to parse patches `)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config:\n a: !!js\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to parse patches `)) }) it('fails loud when the file is not a top-level array or an entry is not an object', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n') - expect(() => loadPersonalPatches(NAME, dir)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'id: not-a-list\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) .toThrow('must be a top-level YAML array of loader patch entries') - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(`${NAME}: personal patches entry 1 in`) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- just-a-string\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(`${NAME}: patches entry 1 in`) }) }) -describe('boot with personal patches', () => { +describe('boot with user patches', () => { function writeTree(dir: string): string { writeFileSync(join(dir, 'noop.mjs'), [ 'export const name = "noop"', @@ -118,41 +111,41 @@ describe('boot with personal patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() - const personal = tmp() - writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [ + const userDir = tmp() + writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [ '- id: noop', ' name: ./noop.mjs', ' config:', - ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC', + ' value: !!js process.env.DSH_APP_BOOT_USER_SPEC', '- insert:', - ' - id: personal-extra', + ' - id: user-extra', ' name: ./noop.mjs', '', ].join('\n')) - process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value' - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal)) + process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value' + const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME))) try { const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') // The mounted plugin received the interpolated environment value. - expect(noop?.fiber?.config).toEqual({ value: 'personal-value' }) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true) + expect(noop?.fiber?.config).toEqual({ value: 'user-value' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true) } finally { await ctx.fiber.dispose() - delete process.env['DSH_APP_BOOT_PERSONAL_SPEC'] + delete process.env['DSH_APP_BOOT_USER_SPEC'] } }) - it('mounts no patch layer for an absent or empty personal overlay', async () => { + it('mounts no patch layer for an absent or empty user layer', async () => { const dir = tmp() - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp())) + const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))) try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' }) } finally { await ctx.fiber.dispose() } const empty = tmp() - writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n') - const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty)) + writeFileSync(join(empty, PROFILE_PATCH_FILENAME), '[]\n') + const ctxEmpty = await boot(NAME, writeTree(tmp()), loadOptionalPatches(NAME, join(empty, PROFILE_PATCH_FILENAME))) try { expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' }) } finally { @@ -162,8 +155,8 @@ describe('boot with personal patches', () => { it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { const dir = tmp() - const personal = tmp() - const filename = join(personal, PERSONAL_CONFIG_FILENAME) + const userDir = tmp() + const filename = join(userDir, PROFILE_PATCH_FILENAME) const basePatches = [{ id: 'noop', config: { value: 'generated' } }] const ctx = await boot(NAME, writeTree(dir), basePatches) await ctx.plugin(Timer) @@ -172,14 +165,14 @@ describe('boot with personal patches', () => { ctx.on('hmr/config-update-failed', (failedFilename, error) => { failures.push({ filename: failedFilename, error }) }) - const dispose = await watchPersonalPatches(ctx, { + const dispose = await watchUserPatches(ctx, { binName: NAME, - dir: personal, - compose: personalPatches => [...basePatches, ...personalPatches], + filename, + compose: userPatches => [...basePatches, ...userPatches], }) try { writeFileSync(filename, '- id: noop\n config:\n value: live\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied') writeFileSync(filename, '- id: noop\n config:\n fail: true\n') await eventually(() => failures.length === 1, 'failed candidate was not broadcast') @@ -199,17 +192,17 @@ describe('boot with personal patches', () => { await settleChokidarChangeThrottle() unlinkSync(filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch') expect(failures).toHaveLength(2) await settleChokidarChangeThrottle() - // Default compose: the personal overlay IS the whole patch list, so a + // Default compose: the user layer IS the whole patch list, so a // fresh generation replaces the app-owned layer instead of stacking on it. await dispose() - const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) + const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename }) try { writeFileSync(filename, '- id: noop\n config:\n value: identity\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied') } finally { await disposeDefault() } @@ -222,7 +215,7 @@ describe('boot with personal patches', () => { it('fails loud when the exact watcher lacks HMR or a root Include', async () => { const dir = tmp() const withoutHmr = await boot(NAME, writeTree(dir)) - await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service') + await expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service') await withoutHmr.fiber.dispose() const withoutInclude = new Context() @@ -230,7 +223,7 @@ describe('boot with personal patches', () => { await withoutInclude.plugin(Loader) await withoutInclude.plugin(Timer) await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') + await expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry') await withoutInclude.fiber.dispose() }) @@ -245,7 +238,7 @@ describe('boot with personal patches', () => { try { const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() }) + const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) await expect(dispose()).resolves.toBeUndefined() } finally { await ctx.fiber.dispose() @@ -254,14 +247,14 @@ describe('boot with personal patches', () => { it('propagates registration failures other than mid-teardown', async () => { const dir = tmp() - const personal = tmp() + const filename = join(tmp(), PROFILE_PATCH_FILENAME) const ctx = await boot(NAME, writeTree(dir)) try { await ctx.plugin(Timer) await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) - // Same personal path registered twice: HMR refuses; not a teardown race. - await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered') + const dispose = await watchUserPatches(ctx, { binName: NAME, filename }) + // Same user-layer path registered twice: HMR refuses; not a teardown race. + await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered') await dispose() } finally { await ctx.fiber.dispose() diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index e44b171c37..797e0cbfea 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -72,7 +72,7 @@ export class HarnessSdkServer { const payload: SessionEventNotification = { sessionId: String(session.id), event } this.transport.notify('session.event', payload) })) - this.disposers.push(ctx.on('agent/status', (agent, status) => { + this.disposers.push(ctx.on('agent/status', ({ agent, status }) => { this.transport.notify('session.status', { sessionId: String(agent.session.id), status }) })) this.disposers.push(ctx.on('session/created', (session) => { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 78f3e11983..c9d1944781 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -254,8 +254,8 @@ describe('HarnessSdkServer', () => { session, } satisfies Pick) as Agent - ctx.emit('agent/status', agent, 'running') - ctx.emit('agent/status', agent, 'idle') + ctx.emit('agent/status', { agent, status: 'running' }) + ctx.emit('agent/status', { agent, status: 'idle' }) expect(transport.notifications.filter(notification => notification.method === 'session.status')) .toEqual([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d4ec78a4a..bf403ab6eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,348 +140,45 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:* version: link:../../vendor/timer - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../packages/core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../packages/core/agent-loop '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot - '@deepseek-ai/dsh-bash-env': + '@deepseek-ai/dsh-base': specifier: workspace:^ - version: link:../../packages/bash/bash-env - '@deepseek-ai/dsh-bash-local': + version: link:../../packages/bundle/base + '@deepseek-ai/dsh-headless': specifier: workspace:^ - version: link:../../packages/bash/bash-local - '@deepseek-ai/dsh-bash-sandbox': - specifier: workspace:^ - version: link:../../packages/bash/bash-sandbox - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../packages/client/connection - '@deepseek-ai/dsh-client-hmr': - specifier: workspace:^ - version: link:../../packages/client/hmr - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../packages/client/locale - '@deepseek-ai/dsh-client-modules': - specifier: workspace:^ - version: link:../../packages/client/modules - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../packages/client/runtime - '@deepseek-ai/dsh-client-ui-command': - specifier: workspace:^ - version: link:../../packages/client/ui-command - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../../packages/client/ui-conversation - '@deepseek-ai/dsh-client-ui-goal': - specifier: workspace:^ - version: link:../../packages/client/ui-goal - '@deepseek-ai/dsh-client-ui-layout': - specifier: workspace:^ - version: link:../../packages/client/ui-layout - '@deepseek-ai/dsh-client-ui-model': - specifier: workspace:^ - version: link:../../packages/client/ui-model - '@deepseek-ai/dsh-client-ui-models': - specifier: workspace:^ - version: link:../../packages/client/ui-models - '@deepseek-ai/dsh-client-ui-permission': - specifier: workspace:^ - version: link:../../packages/client/ui-permission - '@deepseek-ai/dsh-client-ui-plan': - specifier: workspace:^ - version: link:../../packages/client/ui-plan - '@deepseek-ai/dsh-client-ui-question': - specifier: workspace:^ - version: link:../../packages/client/ui-question - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../../packages/client/ui-settings - '@deepseek-ai/dsh-client-ui-settings-general': - specifier: workspace:^ - version: link:../../packages/client/ui-settings-general - '@deepseek-ai/dsh-client-ui-sidebar': - specifier: workspace:^ - version: link:../../packages/client/ui-sidebar - '@deepseek-ai/dsh-client-ui-skill': - specifier: workspace:^ - version: link:../../packages/client/ui-skill - '@deepseek-ai/dsh-client-ui-slash': - specifier: workspace:^ - version: link:../../packages/client/ui-slash - '@deepseek-ai/dsh-client-ui-subagent': - specifier: workspace:^ - version: link:../../packages/client/ui-subagent - '@deepseek-ai/dsh-client-ui-theme': - specifier: workspace:^ - version: link:../../packages/client/ui-theme - '@deepseek-ai/dsh-client-ui-trajectory': - specifier: workspace:^ - version: link:../../packages/client/ui-trajectory - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../packages/client/ui-workspace - '@deepseek-ai/dsh-code-runtime-worker': - specifier: workspace:^ - version: link:../../packages/code-runtime/code-runtime-worker - '@deepseek-ai/dsh-command-compact': - specifier: workspace:^ - version: link:../../packages/compact/command-compact - '@deepseek-ai/dsh-command-goal': - specifier: workspace:^ - version: link:../../packages/goal/command-goal - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../packages/ui/commands - '@deepseek-ai/dsh-compact-basic': - specifier: workspace:^ - version: link:../../packages/compact/compact-basic - '@deepseek-ai/dsh-compact-tool-result-prune': - specifier: workspace:^ - version: link:../../packages/compact/compact-tool-result-prune - '@deepseek-ai/dsh-credentials-local': - specifier: workspace:^ - version: link:../../packages/credentials/credentials-local - '@deepseek-ai/dsh-frontend': - specifier: workspace:^ - version: link:../web - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../packages/fs/fs-local - '@deepseek-ai/dsh-fs-policy': - specifier: workspace:^ - version: link:../../packages/fs/fs-policy - '@deepseek-ai/dsh-fs-sandbox': - specifier: workspace:^ - version: link:../../packages/fs/fs-sandbox - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../packages/goal/goal - '@deepseek-ai/dsh-goal-session': - specifier: workspace:^ - version: link:../../packages/goal/goal-session - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-auto': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-auto - '@deepseek-ai/dsh-host-directory-picker-browse': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-browse - '@deepseek-ai/dsh-host-directory-picker-native': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-native - '@deepseek-ai/dsh-host-webserver': - specifier: workspace:^ - version: link:../../packages/host/webserver - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../packages/llm/llm - '@deepseek-ai/dsh-llm-deepseek': - specifier: workspace:^ - version: link:../../packages/llm/llm-deepseek - '@deepseek-ai/dsh-llm-pi-ai': - specifier: workspace:^ - version: link:../../packages/llm/llm-pi-ai - '@deepseek-ai/dsh-llm-retry': - specifier: workspace:^ - version: link:../../packages/llm/llm-retry + version: link:../../packages/bundle/headless '@deepseek-ai/dsh-mcp-client': specifier: workspace:^ version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../packages/ui/permission - '@deepseek-ai/dsh-plan-mode': - specifier: workspace:^ - version: link:../../packages/plan/plan-mode '@deepseek-ai/dsh-pty': specifier: workspace:^ version: link:../../packages/pty/pty '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local - '@deepseek-ai/dsh-pwsh-local': + '@deepseek-ai/dsh-session-reference': specifier: workspace:^ - version: link:../../packages/bash/pwsh-local - '@deepseek-ai/dsh-repeat-tool-guard': + version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ - version: link:../../packages/guard/repeat-tool-guard - '@deepseek-ai/dsh-repository-plugin': + version: link:../../packages/context/tmux-context + '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ - version: link:../../packages/cordis/repository-plugin - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-policy - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../packages/core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../packages/core/session - '@deepseek-ai/dsh-session-checkpoint-policy': - specifier: workspace:^ - version: link:../../packages/session-persistence/session-checkpoint-policy - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../packages/session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../packages/session-projection/session-projection - '@deepseek-ai/dsh-session-projection-cache': - specifier: workspace:^ - version: link:../../packages/session-projection/session-projection-cache - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../packages/session-query/session-query - '@deepseek-ai/dsh-session-query-sqlite': - specifier: workspace:^ - version: link:../../packages/session-query/session-query-sqlite - '@deepseek-ai/dsh-session-telemetry-otel': - specifier: workspace:^ - version: link:../../packages/telemetry/session-telemetry-otel - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../packages/session-title/session-title - '@deepseek-ai/dsh-session-title-first-message-llm': - specifier: workspace:^ - version: link:../../packages/session-title/session-title-first-message-llm - '@deepseek-ai/dsh-settings-local': - specifier: workspace:^ - version: link:../../packages/settings/settings-local - '@deepseek-ai/dsh-skill': - specifier: workspace:^ - version: link:../../packages/skill/skill - '@deepseek-ai/dsh-skill-local': - specifier: workspace:^ - version: link:../../packages/skill/skill-local - '@deepseek-ai/dsh-spill-local': - specifier: workspace:^ - version: link:../../packages/spill/spill-local - '@deepseek-ai/dsh-spill-policy': - specifier: workspace:^ - version: link:../../packages/spill/spill-policy - '@deepseek-ai/dsh-storage': - specifier: workspace:^ - version: link:../../packages/storage/storage - '@deepseek-ai/dsh-storage-domain': - specifier: workspace:^ - version: link:../../packages/storage/storage-domain - '@deepseek-ai/dsh-storage-json': - specifier: workspace:^ - version: link:../../packages/storage/storage-json - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../../packages/subagent/subagent - '@deepseek-ai/dsh-subagent-fork': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-fork - '@deepseek-ai/dsh-subagent-spawn': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-spawn - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../packages/subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../packages/core/system-prompt - '@deepseek-ai/dsh-tasks-local': - specifier: workspace:^ - version: link:../../packages/tasks/tasks-local - '@deepseek-ai/dsh-timeout-policy': - specifier: workspace:^ - version: link:../../packages/timeout/timeout-policy - '@deepseek-ai/dsh-token-meter': - specifier: workspace:^ - version: link:../../packages/llm/token-meter - '@deepseek-ai/dsh-tool-bash': - specifier: workspace:^ - version: link:../../packages/bash/tool-bash + version: link:../../packages/ui/tool-ask-user '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis - '@deepseek-ai/dsh-tool-fs': + '@deepseek-ai/dsh-web-app': specifier: workspace:^ - version: link:../../packages/fs/tool-fs - '@deepseek-ai/dsh-tool-fs-search': - specifier: workspace:^ - version: link:../../packages/fs/tool-fs-search - '@deepseek-ai/dsh-tool-goal': - specifier: workspace:^ - version: link:../../packages/goal/tool-goal - '@deepseek-ai/dsh-tool-pwsh': - specifier: workspace:^ - version: link:../../packages/bash/tool-pwsh - '@deepseek-ai/dsh-tool-ralph': - specifier: workspace:^ - version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-tool-skill': - specifier: workspace:^ - version: link:../../packages/skill/tool-skill - '@deepseek-ai/dsh-tool-str-replace-editor': - specifier: workspace:^ - version: link:../../packages/fs/tool-str-replace-editor - '@deepseek-ai/dsh-tool-subagent': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent - '@deepseek-ai/dsh-tool-subagent-control': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-subagent-report': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-tasks': - specifier: workspace:^ - version: link:../../packages/tasks/tool-tasks - '@deepseek-ai/dsh-tool-todo': - specifier: workspace:^ - version: link:../../packages/todo/tool-todo - '@deepseek-ai/dsh-tool-web': - specifier: workspace:^ - version: link:../../packages/web/tool-web - '@deepseek-ai/dsh-tool-workflow': - specifier: workspace:^ - version: link:../../packages/workflow/tool-workflow - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../packages/core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../packages/ui/user-approval - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../../packages/ui/user-interaction - '@deepseek-ai/dsh-web': - specifier: workspace:^ - version: link:../../packages/web/web - '@deepseek-ai/dsh-web-search-deepseek': - specifier: workspace:^ - version: link:../../packages/web/web-search-deepseek - '@deepseek-ai/dsh-workflow-workerthread': - specifier: workspace:^ - version: link:../../packages/workflow/workflow-workerthread - '@deepseek-ai/dsh-workspace': - specifier: workspace:^ - version: link:../../packages/workspace/workspace - '@deepseek-ai/dsh-workspace-context': - specifier: workspace:^ - version: link:../../packages/context/workspace-context + version: link:../../packages/bundle/web-app commander: specifier: ^15.0.0 version: 15.0.0 @@ -495,6 +192,24 @@ importers: specifier: ^0.1.4 version: 0.1.4 devDependencies: + '@deepseek-ai/dsh-frontend-static': + specifier: workspace:^ + version: link:../../packages/host/frontend-static + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../packages/host/webserver + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../packages/support/loader-smoke + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../packages/core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../packages/core/tools '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -1130,6 +845,375 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bundle/base: + dependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:* + version: link:../../../vendor/hmr + '@cordisjs/plugin-timer': + specifier: workspace:* + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../bash/bash-sandbox + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../compact/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../fs/fs-sandbox + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../goal/goal-session + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../llm/llm-pi-ai + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../plan/plan-mode + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:^ + version: link:../../guard/repeat-tool-guard + '@deepseek-ai/dsh-repository-plugin': + specifier: workspace:^ + version: link:../../cordis/repository-plugin + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../session-persistence/session-checkpoint-policy + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../session-query/session-query-sqlite + '@deepseek-ai/dsh-session-telemetry-otel': + specifier: workspace:^ + version: link:../../telemetry/session-telemetry-otel + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title + '@deepseek-ai/dsh-session-title-first-message-llm': + specifier: workspace:^ + version: link:../../session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:^ + version: link:../../settings/settings-local + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../skill/skill-local + '@deepseek-ai/dsh-spill-local': + specifier: workspace:^ + version: link:../../spill/spill-local + '@deepseek-ai/dsh-spill-policy': + specifier: workspace:^ + version: link:../../spill/spill-policy + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../../subagent/subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../timeout/timeout-policy + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../workflow/tool-ralph + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../fs/tool-str-replace-editor + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-subagent-report': + specifier: workspace:^ + version: link:../../subagent/tool-subagent-report + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../tasks/tool-tasks + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../../ui/user-interaction + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../../web/web + '@deepseek-ai/dsh-web-search-deepseek': + specifier: workspace:^ + version: link:../../web/web-search-deepseek + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../context/workspace-context + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/bundle/headless: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/bundle/web-app: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-hmr': + specifier: workspace:^ + version: link:../../client/hmr + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../client/modules + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../../client/ui-command + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-goal': + specifier: workspace:^ + version: link:../../client/ui-goal + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../../client/ui-layout + '@deepseek-ai/dsh-client-ui-model': + specifier: workspace:^ + version: link:../../client/ui-model + '@deepseek-ai/dsh-client-ui-models': + specifier: workspace:^ + version: link:../../client/ui-models + '@deepseek-ai/dsh-client-ui-permission': + specifier: workspace:^ + version: link:../../client/ui-permission + '@deepseek-ai/dsh-client-ui-plan': + specifier: workspace:^ + version: link:../../client/ui-plan + '@deepseek-ai/dsh-client-ui-question': + specifier: workspace:^ + version: link:../../client/ui-question + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-general': + specifier: workspace:^ + version: link:../../client/ui-settings-general + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../../client/ui-sidebar + '@deepseek-ai/dsh-client-ui-skill': + specifier: workspace:^ + version: link:../../client/ui-skill + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../../client/ui-slash + '@deepseek-ai/dsh-client-ui-subagent': + specifier: workspace:^ + version: link:../../client/ui-subagent + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../../client/ui-theme + '@deepseek-ai/dsh-client-ui-trajectory': + specifier: workspace:^ + version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:^ + version: link:../../code-runtime/code-runtime-worker + '@deepseek-ai/dsh-frontend': + specifier: workspace:^ + version: link:../../../apps/web + '@deepseek-ai/dsh-frontend-static': + specifier: workspace:^ + version: link:../../host/frontend-static + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-auto': + specifier: workspace:^ + version: link:../../host/directory-picker-auto + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../../host/directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session-projection/session-projection-cache + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/connection: dependencies: '@deepseek-ai/dsh-commands': @@ -3738,6 +3822,25 @@ importers: specifier: ^4.19.2 version: 4.22.4 + packages/host/frontend-static: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/host/webserver: dependencies: schemastery: diff --git a/scripts/attribute-chunk-bytes.mjs b/scripts/attribute-chunk-bytes.mjs new file mode 100644 index 0000000000..d7cc1130d9 Binary files /dev/null and b/scripts/attribute-chunk-bytes.mjs differ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 6bd613c2ea..e0b9344cdf 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -102,6 +102,10 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { + // Profile bundles publish their dsh.bundle.patch layer beside the lib. + '@deepseek-ai/dsh-base': ['cordis.patch.yml'], + '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], + '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], diff --git a/scripts/demo-cordis.mjs b/scripts/demo-cordis.mjs index 64fbe0e72d..43a23ab250 100644 --- a/scripts/demo-cordis.mjs +++ b/scripts/demo-cordis.mjs @@ -6,7 +6,7 @@ import { spawn } from 'node:child_process' const SURFACES = new Map([ // The browser surface with the cordis toolset layered on: `dsh web --config` // applies this overlay over the shipped web composition; it owns port 3081. - ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--config', 'examples/web-cordis/cordis.yml']], + ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--patch', 'examples/web-cordis/cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']], ]) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1dd7973fd5..dd00068fe4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -598,7 +598,8 @@ function parseExampleCordis(rel: string): ExamplePlugin[] { if (current?.name) plugins.push({ id: current.id, name: current.name }) } for (const line of text.split('\n')) { - const id = /^-\s+id:\s+(.+?)\s*$/.exec(line) + // Top-level rows (`- id:`) and bundle-patch insert rows (` - id:`). + const id = /^\s*-\s+id:\s+(.+?)\s*$/.exec(line) if (id?.[1] !== undefined) { flush() current = { id: stripYamlScalar(id[1]) } @@ -620,9 +621,9 @@ const APP_EXAMPLES = [ id: 'dsh_base', rel: 'apps/cli/composition.md', title: 'DSH Base Composition', - label: 'apps/cli/config/base.cordis.yml', - config: 'apps/cli/config/base.cordis.yml', - summary: 'The raw CLI applies one required caller-selected patch list over this shared base; Web and headless apply their own shipped overlays.', + label: 'packages/bundle/base/cordis.patch.yml', + config: 'packages/bundle/base/cordis.patch.yml', + summary: 'The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user\'s profile layer patch over it.', }, { id: 'headless', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index d19b42bc8e..f0eb64548e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -392,7 +392,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', diff --git a/scripts/install.sh b/scripts/install.sh index b70652451e..5c9d892f73 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -46,7 +46,7 @@ # DSH_MASTER master clone directory (default: $DSH_SOURCE/master) # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) -# DSH_HOME Harness home holding the personal config (default: ~/.dsh) +# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript # entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 0aa189024d..b9d67f4bf6 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Configured runtime\n\nRaw `dsh` requires a patch-list configuration applied over the shipped base:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nThe [CLI contract](apps/cli/README.md#raw-config) describes the base, overlay semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### 自定义运行时\n\n原始 `dsh` 要求传入一份 patch 列表配置,并将其叠加在随附 base 之上:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nbase、overlay 语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#raw-config)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2d15ec03e5..603ad20d8e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -141,11 +141,6 @@ "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "PreStepContext", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "PreStepDecision", diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index bdb020a6a0..4c4d83ead6 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -149,11 +149,33 @@ function validateExampleResolution(): string[] { } function validateAppResolution(): string[] { - const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} + const violations: string[] = [] + // App overlays (and any config left under apps/cli/config) resolve from the + // dsh app's own dependency surface — the profile module fallback mirrors it. + const appDependencies = { + ...readManifest('apps/cli/package.json').dependencies, + // The fallback also links every bundle's own dependencies (healProfilesModuleFallback). + ...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root }) + .flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))), + } const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') }) .map(file => `apps/cli/config/${file}`)) - const references = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file)) - return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') + const appReferences = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file)) + violations.push(...missingPluginDependencies(appReferences, appDependencies, 'apps/cli/package.json or a bundle manifest')) + // Each bundle's patch rows must resolve from that bundle's own dependencies: + // per-layer resolution anchors on the bundle package directory. + for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) { + const bundleDir = manifestPath.replace(/\/package\.json$/, '') + const manifest = readManifest(manifestPath) + const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`)) + violations.push(...missingPluginDependencies( + // A bundle may mount its own package (the web-app runtime row). + references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), + manifest.dependencies ?? {}, + manifestPath, + )) + } + return violations } /** diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 041972cb9f..316a4233de 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -86,6 +86,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, + 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' }, + 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' }, + 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base/web bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ba9ba5d84..cba3a9972d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -93,6 +93,7 @@ "./packages/spill/*/src/invariant.ts", "./packages/timeout/*/src/invariant.ts", "./packages/todo/*/src/invariant.ts", + "./packages/bundle/*/src/invariant.ts", "./packages/cordis/*/src/invariant.ts", "./packages/sandbox/*/src/invariant.ts", "./packages/hooks/*/src/invariant.ts", @@ -191,6 +192,7 @@ "./packages/spill/*/src", "./packages/timeout/*/src", "./packages/todo/*/src", + "./packages/bundle/*/src", "./packages/cordis/*/src", "./packages/sandbox/*/src", "./packages/hooks/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 1a0f4698f7..a3161bced0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -189,6 +189,9 @@ { "path": "./packages/support/agent-loop-testkit" }, { "path": "./packages/acp/acp" }, { "path": "./packages/examples/acp-demo" }, + { "path": "./packages/bundle/base" }, + { "path": "./packages/bundle/headless" }, + { "path": "./packages/bundle/web-app" }, { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, @@ -232,6 +235,7 @@ // client aggregate's webserver reference. { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, + { "path": "./packages/host/frontend-static" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, diff --git a/vendor/README.md b/vendor/README.md index c59a86ccca..9fa97413c2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,8 +41,8 @@ Keep this log exhaustive — every divergence from upstream must be listed. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. -13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the raw invalid-provider built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. ## Sync procedure diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 2484d0152a..00864cd865 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -215,7 +215,7 @@ class Hmr extends Service { // the scan-triggered refresh waits on that apply — a teardown deadlock // that strands boot without a diagnostic. Only events after the scan // matter here; `registerConfig` keeps its own initial scan because a - // personal config present at registration must apply once. + // user patch layer present at registration must apply once. ignoreInitial: true, }) diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 4a9fd6be86..26b9305c52 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -85,10 +85,10 @@ export function applyEntryPatches( data.push(...insert) } // Index what this patch added so a LATER patch in the same list can - // target it. Patch lists compose one layer per source (surface overlay, - // then `--config`, then the user's), and a layer must be able to - // configure or disable a row an earlier layer inserted; without this, - // inserted rows were silently unpatchable. + // target it. Patch lists compose one layer per source (each bundle + // layer, then the user's, then `--patch` overlays), and a layer must be + // able to configure or disable a row an earlier layer inserted; without + // this, inserted rows were silently unpatchable. buildMap(insert) continue } diff --git a/website/docs.ts b/website/docs.ts index 8d42ae3209..1a9b20b5be 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -166,6 +166,14 @@ const develop = pairedPages([ section: { root: '基础', en: 'Basics' }, order: 3, }, + { + source: 'docs/user/develop/basic/publish.md', + route: 'develop/basic/publish.md', + label: { root: '打包与安装插件', en: 'Package and install' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, + order: 4, + }, { source: 'docs/user/develop/framework/index.md', route: 'develop/framework/index.md',