Web background-job display
Web 后台任务展示
`ctx.jobs` already runs every long-lived piece of work the harness starts in the background — `bash`, `pwsh`, `pty-send`, and one-shot background subagents — but its only reader was the model. [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.md) exposes `job_list`, `job_output`, and `job_kill`, and nothing else observed the registry. A human at the Web client therefore could not see that a build was runni
English
Problem
ctx.jobs already runs every long-lived piece of work the harness starts in the background — bash, pwsh, pty-send, and one-shot background subagents — but its only reader was the model. dsh-tool-jobs exposes job_list, job_output, and job_kill, and nothing else observed the registry.
A human at the Web client therefore could not see that a build was running, could not distinguish a finished task from a stuck one, and could not stop one. The only trace was the run_in_background tool card that printed a job id somewhere earlier in the transcript, and that card never updates again.
The session header was already the place where per-session background activity lives: dsh-client-ui-subagent contributes the subagent catalog to conversation.session.header.actions. Placement was settled. What was missing was any channel at all that carried task state to a browser.
Decision
Task state reaches the browser as one whole-snapshot mux frame per session, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping.
This ships the list alone. Per-task streamed output and a human-initiated cancellation are separate phases, and the channel is shaped so neither has to undo it.
Wire shape
One frame in the mux stream:
| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] }
JobView is browser-safe and owned by the carrier at packages/host/apiproxy/src/api/jobs.ts, alongside the other domain contracts, with its wire schema beside it in jobs.schema.ts:
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
export interface JobView {
id: JobId
kind: string
label: string
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
detail?: string
startedAt: number
finishedAt?: number
}
JobId comes from the cordis-free @deepseek-ai/dsh-jobs/brand leaf — the same arrangement as the @deepseek-ai/dsh-llm/brand import api/subagents.ts already uses, because the dsh-jobs root reaches dsh-agent and is unreachable from a client program even as a type. Like every other non-root subpath in this workspace, it carries an explicit tsconfig.base.json paths entry; without one the Typert analyzer resolves the specifier to lib/types/ and rejects the reference as unexported.
kind is string on the wire rather than JobKind. The kind map is merge-extensible by producer plugins, so a client build cannot enumerate the closed set; presentation falls through a documented default for an unrecognized kind.
Three JobSnapshot fields are deliberately absent: ownerSession (the frame's sessionId already carries it), reported (an internal notice-delivery bit with no user meaning), and outputLimitBytes (producer-owned model-presentation policy).
The frame carries a whole snapshot rather than a delta for the reason session/queue states for itself: start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small.
The task-registry change feed
JobRegistry owns one observation method:
abstract onJobsChanged(listener: JobsChangedListener): () => void
It fires after every commit that changes what list(owner) returns: registration at the end of start(), the stopping transition in kill(), settlement, and the removal disposeOwner() performs. An undefined owner means an unowned task changed, and therefore every caller's view changed.
The listener is owner-granular rather than task-granular. The only consumer pushes whole snapshots, so a per-job record would be discarded on arrival — and a per-task feed cannot express the owner-disposal removal at all without inventing a tombstone status nothing else needs.
onJobDone is not a subset of this. It delivers the terminal record with the exact owner Agent under first-wins semantics that dsh-tool-jobs couples to reported; onJobsChanged is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching onJobDone, and each registration is an effect on the calling fiber.
Service disposal deliberately announces nothing. Every onJobsChanged registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set.
The api-proxy carrier
mux() subscribes ctx.jobs.onJobsChanged and pushes session/jobs; the subscription baseline rides next to the existing session/subscribed control frames, so a reconnecting client is current before it renders.
Four rules the carrier keeps:
- Never resume. A change push reads
jobs.list(owner)with the exactAgentthe listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead readsctx.jobs.list(ctx.agents.get(session.id))— the non-resuming registry read, where a session with no live Agent correctly yields only the unowned tasks. Neither path touches theapi-remotesAgent resolver, which resumes a cold session as a side effect of lookup; listing must never revive a session the user merely scrolled past. - Fan out unowned changes. An
undefinedowner pushes a fresh snapshot to every subscribed session, because unowned tasks are visible to every caller. - Stay optional. The carrier reads
ctx.get('jobs'). A composition without the registry emits no frames, and the client renders no entry point — the posturesessionProjectionsalready has in this file. - Say nothing about nothing. The baseline is pushed only for sessions whose list is non-empty, and an absent key on the client means an empty list. A change that empties a list still pushes
[], because that one transition is the only thing the client cannot infer from absence.
The client mirror
SessionListState carries jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>, owned by SessionManager and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and [] are one representation.
It lives on the list mirror rather than on Session for three reasons: the header action already reads list state through useSessions, nothing needs the pre-instantiation buffering session/queue requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel.
Two clears keep it honest. On re-subscribe the manager drops the session's mirror — the rule session/queue already follows, because a fresh baseline is arriving and this generation sends none for an empty set, so a retained list would survive as a phantom. On host/session-removed it drops the mirror again: owner disposal already removed the records registry-side, but that lands on the mux stream while the removal frame rides the host stream, so the two have no relative order.
The header action
@deepseek-ai/dsh-client-ui-jobs registers one entry in conversation.session.header.actions, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's detail is the only place its failure is legible.
A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing kind: 'subagent' here would leave the cancellation phase with no entry point for exactly those tasks.
What this deliberately does not do
No web path calls ctx.jobs.read(). It consumes the single output cursor, so a browser read would silently take bytes the model's job_output will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site.
No cancellation. That phase owes a decision the seam does not currently answer: kill() marks terminal delivery reported, so a human interrupt written against today's contract would leave the model believing its task is still running.
No output watermark on the frame. The output phase's delta channel is where an anchor field earns its place; one added now would have no reader.
Alternatives considered
Signal frame plus RPC pull, the subagent-catalog shape. Push a payload-free jobs-changed signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in SessionManager: catalogInflight for single-flight, catalogStale for a trailing re-pull when a membership frame lands mid-request, updateCatalogActivity patching loaded rows in place and writing into the in-flight request so a response older than the frame gets overwritten, parentAvailableOverride replaying a stale false, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window.
Popover-scoped polling with no seam change. Cheapest to build and the only option that avoids touching JobRegistry. It cannot support a resident count on the trigger without a resident poll, and both later phases need a real change feed anyway, so it buys a week and spends it back.
A session-projection unit over durable task events. Projection units fold over committed session events, so this would first require task lifecycle to become durable — job/started … job/settled as a standalone open/close bracket, with the last session/end-seed marking any unmatched opener as dead history, exactly as the compaction bracket already does. It is genuinely cheaper on the client: dsh-tool-todo shows the whole pattern in a fifteen-line unit, and the existing session/projection frames, history-tail block, and persisted checkpoint cache would have carried the data with no new wire surface, no carrier subscription, and no manager state. It was rejected because it buys that with a durable format change in service of a browser list, and because it does not extend to the phase it would most need to: spill/ exists precisely so oversized tool output stays out of the log, so streamed job output cannot ride durable events either way. Nothing here forecloses revisiting it if durable task history becomes valuable on its own merits.
Reusing PublicJobSnapshot from dsh-tool-jobs. Nearly the right fields, but it belongs to the model-facing control surface. A wire type a browser program imports from a tool package couples client presentation to prompt-facing decisions and drags a host-only package into a client build.
Folding tasks into the subagent catalog as one "activity" panel. One entry point instead of two. Rejected because SubagentCatalogAction is already 605 lines whose subject is a durable session-lineage tree including finished children; process-scoped tasks are a second data model with different identity, lifetime, and affordances, and the catalog's lazily-expanded branch, duration, and token contracts would all need rewriting to host them.
A host-global task list across every session. The literal reading of "show all running tasks". Rejected because the registry's authorization fence is per-owner-session, so a global read needs a new access rule, and a global list has no business in a session's header — it would need its own home in the sidebar. Nothing in this design blocks adding it later; the per-session frames are the same data.
Testing
The web e2e scenario is the end-to-end proof and runs keyless: a real run_in_background bash call registers with ctx.jobs, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer.
Below it, jobs-local pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; api-proxy-jobs pins the baseline-only-when-non-empty rule, the three change pushes, the dropped internal fields, the unowned fan-out, the no-resume guarantee, and the registry-absent composition; and the client suites pin the last-wins fold, the absent-key representation, both clears, and the component's ordering, duration, and dismissal behavior.
Consequences
A missed commit point leaks rows. If disposeOwner() removal ever stops firing the feed, the client keeps tasks that no longer exist until the session disappears. The whole-snapshot shape makes this recoverable rather than corrupting — the next legitimate change repairs the list — but the disposal path is the one most easily forgotten, so it carries its own test.
Unowned-task fan-out is easy to under-implement. Pushing only to the changed owner's session is correct for owned tasks and silently wrong for unowned ones, which are visible everywhere. The bug would surface only in compositions that create unowned tasks, which is why the carrier suite covers it directly.
The UI set is not the registry's set. The header shows what one session can see, so a task owned by another session never appears in it even though the registry holds it — and because the registry is process-local, a restart empties every list while the transcript still shows the run_in_background cards that started them. Unowned tasks are the opposite case: they reach every session's list, exactly as list(caller) reports them to every caller.
Settled rows accumulate. The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint.
stopping is nearly unreachable today. Only the model's job_kill produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change.
Two entry points for one running subagent. Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind.
A new non-root subpath needs its paths entry. @deepseek-ai/dsh-jobs/brand had to be registered in tsconfig.base.json before the Typert analyzer would accept the reference. The failure mode is a confusing "not exported by" error from a generator far from the edit, so the entry is part of adding a subpath, not an optimization.
中文
问题
ctx.jobs 已经承载了 harness 在后台启动的全部长时工作——bash、pwsh、pty-send,以及一次性后台 subagent——但它唯一的读者是模型。dsh-tool-jobs 暴露了 job_list、job_output 和 job_kill,除此之外没有任何东西观察这个注册表。
于是 Web 端的人类看不到构建正在跑,分不清一个任务是已经完成还是卡死,也无法把它停掉。唯一的痕迹是 transcript 里更早某处那张打印了 job id 的 run_in_background 工具卡片,而那张卡片此后再也不会更新。
会话 header 本来就是每会话后台活动的落点:dsh-client-ui-subagent 把 subagent 目录贡献到 conversation.session.header.actions。位置没有争议。缺的是任何一条把任务状态送到浏览器的通道。
决策
任务状态以每会话一帧的整份快照到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。
本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。
线路形状
mux 流中的一帧:
| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] }
JobView 是浏览器安全类型,由载体在 packages/host/apiproxy/src/api/jobs.ts 里拥有,与其他领域契约并列,线路 schema 就在旁边的 jobs.schema.ts:
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
export interface JobView {
id: JobId
kind: string
label: string
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
detail?: string
startedAt: number
finishedAt?: number
}
JobId 取自不依赖 cordis 的 @deepseek-ai/dsh-jobs/brand 叶子——与 api/subagents.ts 已经在用的 @deepseek-ai/dsh-llm/brand 导入是同一种安排,因为 dsh-jobs 根出口会牵到 dsh-agent,即便只作类型也无法被客户端程序触及。和本仓库其他每一个非根子路径一样,它带有显式的 tsconfig.base.json paths 条目;没有这一条,Typert 分析器会把该 specifier 解析到 lib/types/ 并判定该引用未被导出。
线路上的 kind 是 string 而非 JobKind。kind 映射由生产者插件按声明合并扩展,客户端构建无法枚举这个闭集;遇到无法识别的 kind,呈现层走一条有文档的默认分支。
JobSnapshot 的三个字段被刻意省去:ownerSession(帧的 sessionId 已经带了)、reported(内部的通知投递位,对用户无意义),以及 outputLimitBytes(生产者拥有的模型呈现策略)。
这一帧带整份快照而非增量,理由就是 session/queue 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。
任务注册表变更订阅
JobRegistry 拥有一个观察方法:
abstract onJobsChanged(listener: JobsChangedListener): () => void
它在每一个会改变 list(owner) 返回内容的提交点之后触发:start() 末尾的注册、kill() 里转入 stopping、结算,以及 disposeOwner() 执行的移除。owner 为 undefined 表示一个无主任务发生了变化,因而每一个调用方的视图都变了。
监听器按 owner 而非按任务分粒度。唯一的消费方推的是整份快照,逐任务记录到手即弃——而且逐任务的订阅根本无法表达 owner 销毁时的移除,除非发明一个别处都不需要的墓碑状态。
onJobDone 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner Agent,dsh-tool-jobs 把这套语义与 reported 绑在一起;onJobsChanged 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 onJobDone 一致,每次注册都是调用方 fiber 上的 effect。
服务销毁刻意什么都不通告。每个 onJobsChanged 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。
api-proxy 载体
mux() 订阅 ctx.jobs.onJobsChanged 并推送 session/jobs;订阅 baseline 紧挨着既有的 session/subscribed 控制帧发出,让重连的客户端在渲染前就是最新的。
载体守着四条规则:
- 绝不 resume。 变更推送用监听器给出的确切
Agent调jobs.list(owner),即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读ctx.jobs.list(ctx.agents.get(session.id))——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰api-remotes的 Agent 解析器,那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。 - 无主变更要扇出。
owner为undefined时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。 - 保持可选。 载体读
ctx.get('jobs')。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——sessionProjections在这个文件里已经是这个姿态。 - 没有就不说。 baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推
[],因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。
客户端镜像
SessionListState 带有 jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>,由 SessionManager 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 [] 成为同一种表示。
它放在列表镜像而不是 Session 上,有三个理由:header 入口本来就通过 useSessions 读列表状态;没有任何东西需要 session/queue 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。
两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——session/queue 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。host/session-removed 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。
header 入口
@deepseek-ai/dsh-client-ui-jobs 在 conversation.session.header.actions 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 detail 是其失败唯一可读之处。
因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 kind: 'subagent' 会让中断那一期恰好对这批任务没有入口。
刻意不做的事
没有任何 Web 路径调用 ctx.jobs.read()。 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 job_output 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。
不做中断。 那一期欠一个 seam 目前没有回答的决策:kill() 会把终态投递标为已上报,所以照今天的契约写出来的人类中断,会让模型一直以为它的任务还在跑。
帧上不带输出水位。 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。
备选方案
信号帧加 RPC 拉取,即 subagent 目录的形状。 推一个无 payload 的 jobs-changed 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 SessionManager 里一览无余:catalogInflight 做单飞行、catalogStale 在成员帧落于请求中途时补一次尾拉、updateCatalogActivity 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、parentAvailableOverride 重放一个过期的 false,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。
只在弹层打开时轮询,不改 seam。 最省事,也是唯一不碰 JobRegistry 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。
基于持久任务事件的 session-projection 单元。 投影单元在已提交的会话事件上折叠,所以这条路要先让任务生命周期变持久——job/started … job/settled 作为一对独立的开合括号,由最后一个 session/end-seed 把未配对的开括号标为死历史,与 compaction 括号已有的做法完全一致。它在客户端确实更省:dsh-tool-todo 用十五行的单元展示了整套模式,而现成的 session/projection 帧、history-tail 块和持久化 checkpoint 缓存本可以承载这批数据,无需新线路面、无需载体订阅、无需 manager 状态。否决它,是因为这要拿一次持久格式变更去换一个浏览器列表,而且它并不能延伸到最需要它的那一期:spill/ 的存在正是为了让超大工具输出留在日志之外,所以流式任务输出无论如何都不能骑在持久事件上。如果持久任务历史将来凭自身价值站得住,本设计不阻挡重新考虑它。
复用 dsh-tool-jobs 的 PublicJobSnapshot。 字段几乎就是对的,但它属于面向模型的控制面。浏览器程序从一个 tool 包导入线路类型,会把客户端呈现耦合到面向 prompt 的决策上,并把一个 host-only 包拖进客户端构建。
并进 subagent 目录做成统一的「活动」面板。 一个入口而不是两个。否决的理由是 SubagentCatalogAction 已经 605 行,其主题是含已结束子会话的持久会话血缘树;进程域的任务是第二套数据模型,身份、生命期和可用动作都不同,而目录的懒展开分支、时长与 token 契约全都要重写才能容纳它们。
跨全部会话的 host 全局任务列表。「显示所有运行中任务」的字面读法。否决是因为注册表的鉴权围栏是按 owner 会话的,全局读需要一条新的访问规则,而且全局列表不该出现在某个会话的 header 里——它需要侧栏里自己的位置。本设计没有阻挡后续再加;按会话的帧就是同一批数据。
测试
web e2e 场景是端到端的证据,且无需密钥:一次真实的 run_in_background bash 调用注册进 ctx.jobs,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。
在它之下,jobs-local 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;api-proxy-jobs 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。
影响
漏掉一个提交点会漏行。 如果 disposeOwner() 的移除有朝一日不再触发订阅,客户端会一直留着已经不存在的任务,直到会话消失。整份快照的形状让这件事可恢复而非损坏——下一次正当变更就修好了——但销毁路径是最容易被忘掉的一条,所以它自带测试。
无主任务的扇出很容易做漏。 只推给变更 owner 所在的会话,对有主任务是对的,对处处可见的无主任务则是悄悄错的。这个 bug 只会在会创建无主任务的组合里显形,所以载体套件直接覆盖了它。
UI 的集合不等于注册表的集合。 header 显示的是「一个会话能看到什么」,所以别的会话拥有的任务在这里永远不出现,尽管注册表里有它;而由于注册表是进程本地的,一次重启会清空所有列表,transcript 里那些启动它们的 run_in_background 卡片却还在。无主任务是反过来的情形:它们会进入每一个会话的列表,正如 list(caller) 对每个调用方都报告它们。
终态行会堆积。 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。
stopping 今天几乎不可达。 只有模型的 job_kill 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。
一个运行中的 subagent 有两个入口。 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。
新增非根子路径必须补 paths 条目。 @deepseek-ai/dsh-jobs/brand 得先登记进 tsconfig.base.json,Typert 分析器才会接受该引用。它的故障表现是一条来自远离改动处的生成器的、令人困惑的「not exported by」错误,所以这个条目是新增子路径的组成部分,而不是优化。