diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx
index 45de33c8f0..886661b396 100644
--- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx
@@ -89,15 +89,30 @@ describe('TerminalBlock prompt label', () => {
})
describe('TerminalBlock states', () => {
- it('running shows the command line only: no output, no placeholder, no copy', () => {
- const view = render(
)
+ it('running with no output shows the command line only: no output box, no placeholder, no copy', () => {
+ const view = render(
)
expect(view.getByText('sleep 5')).toBeTruthy()
- expect(view.queryByText('partial')).toBeNull()
+ expect(outputLines(view.container)).toEqual([])
expect(view.queryByText('无输出')).toBeNull()
expect(view.queryByRole('button')).toBeNull()
expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('')
})
+ it('running with supplied output streams the live text and keeps it copyable', () => {
+ const view = render(
)
+ expect(view.getByText('partial')).toBeTruthy()
+ expect(view.queryByText('无输出')).toBeNull()
+ expect(view.queryByRole('button', { name: '复制' })).toBeTruthy()
+ expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('')
+ })
+
+ it('running with an empty live stream draws neither output nor placeholder', () => {
+ const view = render(
)
+ expect(outputLines(view.container)).toEqual([])
+ expect(view.queryByText('无输出')).toBeNull()
+ expect(view.queryByRole('button')).toBeNull()
+ })
+
it('running still shows a settled-looking status pill when one is supplied', () => {
render(
)
expect(screen.getByText('信号 SIGINT')).toBeTruthy()
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index e038b24a0d..1a53958b6c 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -1141,6 +1141,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
+ 'client-ui-activity ActivityListAction id \'activity-list\'',
'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'',
'client-ui-jobs JobListAction id \'job-list\'',
],
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 320c44b9aa..35c3b93d92 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -81,6 +81,68 @@ export interface TypeApiEntry {
/** Every harness `ctx.
` service, sorted by key. */
export const SERVICE_API: readonly ServiceApiEntry[] = [
+ {
+ key: 'activities',
+ summary: 'Abstract streaming-output registry.',
+ description: 'Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.\n- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.\n- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.\n- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it. Every listener is contained.',
+ methods: [
+ {
+ signature: 'abstract open(spec: ActivityOpen): ActivityHandle',
+ description: 'Validate the spec, attach owner cleanup, and atomically register one activity. A rejection leaves no id; after return the producer owns the handle and the visible set has changed.',
+ parameters: [{ name: 'spec', description: 'activity identity, owner, and correlation.' }],
+ returns: 'the producer face of the registered activity.',
+ },
+ {
+ signature: 'abstract list(caller?: Agent): ActivitySnapshot[]',
+ description: 'List caller-owned and unowned activities in registration order without exposing another session\'s labels or output.',
+ parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned activities.' }],
+ returns: 'fresh snapshots.',
+ },
+ {
+ signature: 'abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot',
+ description: 'Return a fresh snapshot. Throws for an unknown or foreign activity.',
+ parameters: [{ name: 'id', description: 'activity to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }],
+ returns: 'a fresh snapshot.',
+ },
+ {
+ signature: 'abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead',
+ description: 'Read retained output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Throws for an unknown or foreign activity or a negative or non-integer offset.',
+ parameters: [{ name: 'id', description: 'activity to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }],
+ returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.',
+ },
+ {
+ signature: 'abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void',
+ description: 'Register an effect-scoped observer of visible-set changes: opening, detail updates, settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas. Listeners are contained and never awaited.',
+ parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned activity changed and every caller\'s set did.' }],
+ returns: 'disposer that unregisters the listener.',
+ },
+ {
+ signature: 'abstract onOutput(listener: ActivityOutputListener): () => void',
+ description: 'Register an effect-scoped observer of stream advancement — one signal per committed append and one at settlement, carrying only the activity id. A consumer schedules a read from its own cursor; the registry never pushes payloads. Listeners are contained and never awaited.',
+ parameters: [{ name: 'listener', description: 'receives the id whose stream advanced.' }],
+ returns: 'disposer that unregisters the listener.',
+ },
+ ],
+ },
+ {
+ key: 'activityController',
+ summary: 'Host service backing the generated `ctx.remote.activity` namespace.',
+ description: 'Host service backing the generated `ctx.remote.activity` namespace.',
+ methods: [
+ {
+ signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable',
+ description: 'Stream a complete roster baseline followed by whole-bucket replacements.',
+ parameters: [{ name: 'signal', description: 'generation cancellation.' }],
+ returns: 'baseline followed by per-owner roster replacement frames.',
+ },
+ {
+ signature: '@Remote({ mode: \'stream\' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable',
+ description: 'Stream one activity\'s retained output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursors never observe these reads.',
+ parameters: [{ name: 'request', description: 'target activity and optional resume offset.' }, { name: 'signal', description: 'generation cancellation.' }],
+ returns: 'anchor, coalesced output frames, and the terminal status.',
+ },
+ ],
+ },
{
key: 'agentDefaultModel',
summary: 'Owns the default model selection independently of any Host or transport.',
@@ -3115,6 +3177,94 @@ export const EVENT_API: readonly EventApiEntry[] = [
/** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
+ {
+ name: 'ActivitiesChangedListener',
+ declaration: 'export type ActivitiesChangedListener = (owner: Agent | undefined) => void;',
+ },
+ {
+ name: 'ActivityAppendOptions',
+ declaration: 'export interface ActivityAppendOptions {\n channel?: ActivityChannel;\n gapBefore?: true;\n}',
+ },
+ {
+ name: 'ActivityChannel',
+ declaration: 'export type ActivityChannel = \'stdout\' | \'stderr\';',
+ },
+ {
+ name: 'ActivityControlFrame',
+ declaration: 'export type ActivityControlFrame = {\n readonly type: \'baseline\';\n readonly activities: readonly ActivityRow[];\n} | {\n readonly type: \'rows\';\n readonly sessionId?: SessionId;\n readonly activities: readonly ActivityRow[];\n};',
+ },
+ {
+ name: 'ActivityCorrelation',
+ declaration: 'export interface ActivityCorrelation {\n callId?: CallId;\n jobId?: JobId;\n}',
+ },
+ {
+ name: 'ActivityHandle',
+ declaration: 'export interface ActivityHandle {\n readonly id: ActivityId;\n append(text: string, options?: ActivityAppendOptions): void;\n updateDetail(detail: string): void;\n end(outcome: ActivityOutcome): void;\n}',
+ },
+ {
+ name: 'ActivityId',
+ declaration: 'export type ActivityId = Branded<\'ActivityId\'>;',
+ },
+ {
+ name: 'ActivityKind',
+ declaration: 'export type ActivityKind = ActivityKindMap[keyof ActivityKindMap];',
+ },
+ {
+ name: 'ActivityKindMap',
+ declaration: 'export interface ActivityKindMap {\n bash: \'bash\';\n}',
+ },
+ {
+ name: 'ActivityObserveFrame',
+ declaration: 'export type ActivityObserveFrame = {\n readonly type: \'opened\';\n readonly activityId: ActivityId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly ActivityWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};',
+ },
+ {
+ name: 'ActivityObserveRequest',
+ declaration: 'export interface ActivityObserveRequest {\n readonly activityId: ActivityId;\n readonly from?: number;\n}',
+ },
+ {
+ name: 'ActivityOpen',
+ declaration: 'export interface ActivityOpen {\n kind: ActivityKind;\n label: string;\n owner?: Agent;\n correlation?: ActivityCorrelation;\n}',
+ },
+ {
+ name: 'ActivityOutcome',
+ declaration: 'export interface ActivityOutcome {\n status: \'completed\' | \'failed\' | \'killed\';\n detail?: string;\n}',
+ },
+ {
+ name: 'ActivityOutputChunk',
+ declaration: 'export interface ActivityOutputChunk {\n at: number;\n text: string;\n channel?: ActivityChannel;\n gapBefore?: true;\n}',
+ },
+ {
+ name: 'ActivityOutputListener',
+ declaration: 'export type ActivityOutputListener = (id: ActivityId) => void;',
+ },
+ {
+ name: 'ActivityRead',
+ declaration: 'export interface ActivityRead {\n chunks: readonly ActivityOutputChunk[];\n next: number;\n lossy: boolean;\n}',
+ },
+ {
+ name: 'ActivityRow',
+ declaration: 'export interface ActivityRow {\n readonly id: ActivityId;\n readonly kind: string;\n readonly label: string;\n readonly sessionId?: SessionId;\n readonly correlation?: ActivityRowCorrelation;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal: number;\n}',
+ },
+ {
+ name: 'ActivityRowCorrelation',
+ declaration: 'export interface ActivityRowCorrelation {\n readonly callId?: CallId;\n readonly jobId?: JobId;\n}',
+ },
+ {
+ name: 'ActivityRowStatus',
+ declaration: 'export type ActivityRowStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';',
+ },
+ {
+ name: 'ActivitySnapshot',
+ declaration: 'export interface ActivitySnapshot {\n id: ActivityId;\n kind: ActivityKind;\n label: string;\n ownerSession?: SessionId;\n correlation?: ActivityCorrelation;\n status: ActivityStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n outputTotal: number;\n outputEarliest: number;\n}',
+ },
+ {
+ name: 'ActivityStatus',
+ declaration: 'export type ActivityStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';',
+ },
+ {
+ name: 'ActivityWireChunk',
+ declaration: 'export interface ActivityWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}',
+ },
{
name: 'AdapterRegistrationHandle',
declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}',
@@ -4827,9 +4977,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ShellExecSpec',
declaration: 'export interface ShellExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}',
},
+ {
+ name: 'ShellObservedStreams',
+ declaration: 'export interface ShellObservedStreams {\n stdout?: SubprocessOutputReader;\n stderr?: SubprocessOutputReader;\n}',
+ },
{
name: 'ShellProcess',
- declaration: 'export interface ShellProcess {\n status: ShellProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: ShellSandboxInfo;\n readOutput(): ShellProcessRead;\n kill(): boolean;\n}',
+ declaration: 'export interface ShellProcess {\n status: ShellProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: ShellSandboxInfo;\n readOutput(): ShellProcessRead;\n observed?: ShellObservedStreams;\n kill(): boolean;\n}',
},
{
name: 'ShellProcessRead',
diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml
index 8129e170ed..4550338e3b 100644
--- a/packages/jobs/jobs/README.i18n.yaml
+++ b/packages/jobs/jobs/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/jobs/jobs/README.md
-README.md: f4469286603f687be5789bd016ab462dba26e664
-README.zh.md: 6fd97aa3fe4cbbd555c2475f4132ec00c6a97390
+README.md: 1ed69b99ae10d4821e134b42f2ae19ad4ac3350b
+README.zh.md: c9fd920bb552e0386ed0644fe00c4473f5b95e3c
diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md
index f446928660..1ed69b99ae 100644
--- a/packages/jobs/jobs/README.md
+++ b/packages/jobs/jobs/README.md
@@ -35,6 +35,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
-- **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API.
+- **Stream output has one consuming cursor** — the model owns it; independent observers use the non-consuming [`dsh-activity`](../../activity/activity/README.md) plane the producers mirror into instead.
- **Foreground work cannot be promoted** — producers choose foreground or background before starting.
- **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam.
diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md
index 6fd97aa3fe..c9fd920bb5 100644
--- a/packages/jobs/jobs/README.zh.md
+++ b/packages/jobs/jobs/README.zh.md
@@ -35,6 +35,6 @@
## 已知限制与暂缓事项
-- **流输出只有一个消费游标**:独立观察者需要游标或快照 API。
+- **流输出只有一个消费游标**:它属于模型;独立观察者改用生产者镜像进去的非消费 [`dsh-activity`](../../activity/activity/README.zh.md) 观察面。
- **前台工作无法转为后台**:生产方在启动前选择前台或后台。
- **约定是进程内的**:`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。
diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts
index 6c37c5b794..fa4f5ef893 100644
--- a/packages/shell/bash-local/src/index.ts
+++ b/packages/shell/bash-local/src/index.ts
@@ -272,6 +272,7 @@ export class LocalBashExecutor extends ShellExecutor {
status: 'running',
exitCode: null,
signal: null,
+ observed: collected,
done: running.done.then((outcome) => {
// Any signal termination is killed, including a command signaling itself.
if (proc.status === 'running') {
diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts
index b7a2d9f915..08abd61169 100644
--- a/packages/shell/pwsh-local/src/index.ts
+++ b/packages/shell/pwsh-local/src/index.ts
@@ -301,6 +301,7 @@ export class PwshLocalExecutor extends ShellExecutor {
status: 'running',
exitCode: null,
signal: null,
+ observed: collected,
done: running.done.then((outcome) => {
// Any signal termination is killed, including a command signaling itself.
if (proc.status === 'running') {
diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml
index 5317414e55..bcf293269a 100644
--- a/packages/shell/shell/README.i18n.yaml
+++ b/packages/shell/shell/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/shell/shell/README.md
-README.md: a15980ead797a73c18dd6d8825a4b38cf845df60
-README.zh.md: 008b19835697d5d9514cf5472696852cfc684acd
+README.md: 45a4d0c47e7604e7ad2ffc9f98a387cfa4d20b0c
+README.zh.md: da869b78981df6bcfc253e84f08e4ca63cbff5f3
diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md
index a15980ead7..45a4d0c47e 100644
--- a/packages/shell/shell/README.md
+++ b/packages/shell/shell/README.md
@@ -23,6 +23,7 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../
| `start(spec)` | Background execution. Returns a task-free `ShellProcess` handle immediately; **no timeout applies**. The caller may adapt it into `ctx.jobs`. |
| `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. |
| `ShellProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. |
+| `ShellProcess.observed` | Optional **non-consuming** offset readers over the same captured streams, for observers independent of the consuming cursor (the activity observation plane). Absent when a backend cannot expose offset reads. |
| `ShellProcess.kill()` | Kill the process group. Returns `false` when it already finished. |
Implementations subclass `ShellExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md
index 008b198356..da869b7898 100644
--- a/packages/shell/shell/README.zh.md
+++ b/packages/shell/shell/README.zh.md
@@ -23,6 +23,7 @@
| `start(spec)` | 后台执行。立即返回不含任务语义的 `ShellProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.jobs`。 |
| `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 |
| `ShellProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区容量限制而丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
+| `ShellProcess.observed` | 可选的**非消费** offset 读取器,覆盖同一批捕获流,供独立于消费游标的观察者使用(activity 观察面)。后端无法提供 offset 读取时缺省。 |
| `ShellProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
实现会继承 `ShellExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。
diff --git a/packages/shell/shell/src/types.ts b/packages/shell/shell/src/types.ts
index 21fea2d791..1de1cabf95 100644
--- a/packages/shell/shell/src/types.ts
+++ b/packages/shell/shell/src/types.ts
@@ -8,10 +8,22 @@
*/
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
-import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess'
+import type { CollectedOutput, DshEnvironment, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess'
-export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess'
+export type { CollectedOutput, DshEnvironment, DshEnvironmentKey, SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
+
+/**
+ * Non-consuming offset readers over a background process's captured streams,
+ * for observers independent of the consuming {@link ShellProcess.readOutput}
+ * cursor. A stream is absent when its backend cannot expose offset reads.
+ */
+export interface ShellObservedStreams {
+ /** Offset reader over captured stdout, when the backend exposes one. */
+ stdout?: SubprocessOutputReader
+ /** Offset reader over captured stderr, when the backend exposes one. */
+ stderr?: SubprocessOutputReader
+}
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
@@ -175,6 +187,13 @@ export interface ShellProcess {
* full-stream spill files when available.
*/
readOutput(): ShellProcessRead
+ /**
+ * Non-consuming offset readers over the same captured streams the consuming
+ * {@link readOutput} cursor drains. Independent observers read here at their
+ * own offsets without stealing bytes from `readOutput`. Absent when the
+ * backend cannot expose offset reads; observers then see no stream.
+ */
+ observed?: ShellObservedStreams
/**
* Kill the process group. Returns false when it had already finished
* (no-op); idempotent.
diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml
index f3acda2aca..ae0daca26d 100644
--- a/packages/shell/tool-bash/README.i18n.yaml
+++ b/packages/shell/tool-bash/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/shell/tool-bash/README.md
-README.md: 21749bf0a1cd3cdc46257fb2a02ba6b4ab1f5ee6
-README.zh.md: 860db1a4c347d1aefe04f70e14a65e4b511ff7f6
+README.md: acf7e68cca6b611adf71ed7bad690294f1bdb14d
+README.zh.md: 9aeae12bce13f87246a7cdea8c5b41ec8bbd4bc5
diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md
index 21749bf0a1..acf7e68cca 100644
--- a/packages/shell/tool-bash/README.md
+++ b/packages/shell/tool-bash/README.md
@@ -34,7 +34,7 @@ Result text contains stdout, an optional `[stderr]` section, then applicable san
The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process or `{ kind: 'background', jobId }` for a published task. The Native renderer preserves the text above, including exactly `started background job `; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `ShellRunResult` and carry their spill paths.
-When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
+When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort: a `bash` activity correlated to the call and job pumps the handle's non-consuming `observed` readers at `activityPollMs` (default 150) so the Web client streams live output without touching the job's cursor; any observation failure is logged and swallowed.
## UI presentation
diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md
index 860db1a4c3..9aeae12bce 100644
--- a/packages/shell/tool-bash/README.zh.md
+++ b/packages/shell/tool-bash/README.zh.md
@@ -34,7 +34,7 @@
已完成前台进程的规范成功值为 `{ kind: 'foreground', ...ShellRunResult }`,已发布任务则为 `{ kind: 'background', jobId }`。Native renderer 保留上述文本,包括精确的 `started background job `;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `ShellRunResult` 的采集限制,并携带其 spill 路径。
-当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。
+当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。当可选的 `ctx.activities` 注册表已加载时,已提交的后台运行还会尽力镜像进去:一个与调用和 job 关联的 `bash` activity 以 `activityPollMs`(默认 150)的节奏泵取句柄的非消费 `observed` 读取器,让 Web 客户端在不动 job 游标的前提下实时看到输出;任何观察失败只记日志并吞掉。
## UI 展示
diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json
index 321e2822d2..557fbe37f2 100644
--- a/packages/shell/tool-bash/package.json
+++ b/packages/shell/tool-bash/package.json
@@ -32,42 +32,44 @@
],
"license": "MIT",
"peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
- "@deepseek-ai/dsh-shell": "workspace:^",
- "@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+ "@deepseek-ai/dsh-shell": "workspace:^",
+ "@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/dsh-user-approval": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/dsh-user-approval": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
- "@deepseek-ai/dsh-shell": "workspace:^",
- "@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-jobs": "workspace:^",
+ "@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
- "@deepseek-ai/dsh-subprocess-local": "workspace:^",
+ "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+ "@deepseek-ai/dsh-shell": "workspace:^",
+ "@deepseek-ai/dsh-shell-env": "workspace:^",
+ "@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/dsh-jobs": "workspace:^",
- "@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/dsh-user-approval": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/dsh-user-approval": "workspace:^"
}
}
diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts
index 37c4a762e3..ed4fd5bac6 100644
--- a/packages/shell/tool-bash/src/index.ts
+++ b/packages/shell/tool-bash/src/index.ts
@@ -23,8 +23,10 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell'
-import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
+import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
+import type {} from '@deepseek-ai/dsh-activity'
import { processOutcome } from './background.ts'
+import { observeBackgroundActivity } from './observe.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
export const name = 'tool-bash'
@@ -34,11 +36,18 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
+ /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */
+ activityPollMs?: number
}
/** Runtime configuration schema for the bash tool plugin. */
export const Config: z = z.object({
enableRunInBackground: z.boolean().default(true),
+ activityPollMs: z.number()
+ .step(1)
+ .min(1)
+ .max(Number.MAX_SAFE_INTEGER)
+ .default(150),
})
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
@@ -189,6 +198,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
+ const activityPollMs = config.activityPollMs ?? 150
const defaultMode = ctx.shell.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
@@ -362,12 +372,14 @@ export function apply(ctx: Context, config: Config = {}): void {
throw error
}
// Task preflight finishes before the starter can spawn a process.
+ let started: ShellProcess | undefined
const id = jobs.start({
kind: 'bash',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
run: () => {
const proc = ctx.shell.start(ctx.shell.resolve(request))
+ started = proc
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
@@ -375,6 +387,16 @@ export function apply(ctx: Context, config: Config = {}): void {
}
},
})
+ // The starter ran synchronously inside the committed start; mirror the
+ // spawned process into the optional observation registry.
+ if (started !== undefined) {
+ observeBackgroundActivity(ctx, started, {
+ kind: 'bash',
+ label: args.command,
+ ...exec.agent ? { owner: exec.agent } : {},
+ correlation: { callId: exec.callId, jobId: id },
+ }, activityPollMs, processOutcome)
+ }
return { kind: 'background' as const, jobId: id }
}
const result = await ctx.shell.run(ctx.shell.resolve({
diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts
new file mode 100644
index 0000000000..7b5179aa59
--- /dev/null
+++ b/packages/shell/tool-bash/src/observe.ts
@@ -0,0 +1,72 @@
+/**
+ * Best-effort process-observation tap for background bash runs: mirrors the
+ * spawned process's captured streams into `ctx.activities` so non-consuming
+ * observers (the Web client) see live output without touching the job's
+ * consuming `readOutput` cursor. Absent registry or absent backend offset
+ * readers degrade to no observation; the job path is unaffected either way.
+ *
+ * @module @deepseek-ai/dsh-tool-bash/observe
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ShellProcess } from '@deepseek-ai/dsh-shell'
+import { pumpActivityOutput } from '@deepseek-ai/dsh-activity'
+import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity'
+
+/** Identity of one observed background run. */
+export interface ObserveBackgroundSpec {
+ /** Process kind — the producing tool's registered {@link ActivityKind}. */
+ kind: ActivityKind
+ /** One-line label (the command). */
+ label: string
+ /** Owning live agent, when the call has one. */
+ owner?: Agent
+ /** Links to the starting tool call and the registered job. */
+ correlation: ActivityCorrelation
+}
+
+/**
+ * Open a process beside a committed background job and pump the handle's
+ * non-consuming offset readers into it until the run settles, then record the
+ * mapped outcome. Observation is strictly best-effort: without a
+ * `ctx.activities` registry this is a no-op, without backend offset readers
+ * the process still carries status and settlement, and ANY observation
+ * failure — including a rejected `open()` — is logged and swallowed, because
+ * the background job is already committed and running.
+ * @param ctx - plugin context used for the optional registry and warning logs.
+ * @param proc - the started background process handle.
+ * @param spec - process identity and correlation for the registry row.
+ * @param pollMs - producer-configured pump cadence in milliseconds.
+ * @param outcome - maps the settled handle to the process outcome.
+ */
+export function observeBackgroundActivity(
+ ctx: Context,
+ proc: ShellProcess,
+ spec: ObserveBackgroundSpec,
+ pollMs: number,
+ outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string },
+): void {
+ try {
+ const processes = ctx.get('activities')
+ if (processes === undefined) return
+ const handle = processes.open({
+ kind: spec.kind,
+ label: spec.label,
+ ...spec.owner !== undefined ? { owner: spec.owner } : {},
+ correlation: spec.correlation,
+ })
+ const sources: ActivityPumpSource[] = []
+ const stdout = proc.observed?.stdout
+ if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) })
+ const stderr = proc.observed?.stderr
+ if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) })
+ void pumpActivityOutput(handle, sources, { pollMs, done: proc.done })
+ .catch((error: unknown) => {
+ ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`)
+ })
+ .then(() => { handle.end(outcome(proc)) })
+ } catch (error: unknown) {
+ ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`)
+ }
+}
diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts
new file mode 100644
index 0000000000..c0243ba5b9
--- /dev/null
+++ b/packages/shell/tool-bash/tests/observe.spec.ts
@@ -0,0 +1,194 @@
+import { mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
+import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
+import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
+import type { ActivitySnapshot } from '@deepseek-ai/dsh-activity'
+import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
+import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
+import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
+
+const testToolSignal = new AbortController().signal
+const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-'))
+
+/** Job harness plus the observation registry, with a fast pump for tests. */
+async function setup() {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(LocalJobRegistry)
+ await ctx.plugin(ToolTasks)
+ await ctx.plugin(LocalActivityRegistry)
+ await ctx.plugin(LocalSubprocessRuntime)
+ ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
+ await ctx.plugin(BashEnvPlugin)
+ await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
+ await ctx.plugin(ToolBash, { activityPollMs: 25 })
+ return ctx
+}
+
+let callCounter = 0
+function call(ctx: Context, args: Record) {
+ return ctx.tools.execute({
+ signal: testToolSignal,
+ callId: CallId(`observe-call-${++callCounter}`),
+ name: 'bash',
+ arguments: args,
+ })
+}
+
+function text(result: { content: { type: string; text?: string }[] }): string {
+ return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
+}
+
+async function until(read: () => T | undefined, timeoutMs = 5_000): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ const value = read()
+ if (value !== undefined) return value
+ await new Promise(resolve => setTimeout(resolve, 20))
+ }
+ throw new Error('condition not reached before timeout')
+}
+
+function retainedText(ctx: Context, id: ActivitySnapshot['id']): string {
+ return ctx.activities.read(id, 0).chunks.map(chunk => chunk.text).join('')
+}
+
+describe('background bash observation', () => {
+ it('mirrors a background run into ctx.activities with correlation, live output, and settlement', async () => {
+ const ctx = await setup()
+ const ack = await call(ctx, {
+ command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"',
+ description: 'test command',
+ run_in_background: true,
+ })
+ expect(text(ack)).toContain('started background job')
+ const job = ctx.jobs.list()[0]
+ expect(job).toBeDefined()
+
+ const snapshot = await until(() =>
+ ctx.activities.list().find(row => row.correlation?.jobId === job!.id))
+ expect(snapshot.kind).toBe('bash')
+ expect(snapshot.label).toContain('printf')
+ expect(String(snapshot.correlation?.callId)).toMatch(/^observe-call-/)
+
+ // Live output appears while the command is still running.
+ await until(() => retainedText(ctx, snapshot.id).includes('line-1') ? true : undefined)
+ expect(ctx.activities.get(snapshot.id).status).toBe('running')
+
+ // Settlement carries the mapped bash outcome and the trailing output.
+ await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined)
+ expect(ctx.activities.get(snapshot.id).detail).toBe('exit code: 0')
+ expect(retainedText(ctx, snapshot.id)).toContain('line-2')
+
+ // The model-facing consuming cursor still delivers everything: observation stole nothing.
+ const collected = await until(() => {
+ const read = ctx.jobs.read(job!.id)
+ return read.text.includes('line-1') ? read.text : undefined
+ })
+ expect(collected).toContain('line-2')
+ })
+
+ it('a killed background job settles its mirrored process as killed', async () => {
+ const ctx = await setup()
+ await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true })
+ const job = ctx.jobs.list()[0]
+ const snapshot = await until(() =>
+ ctx.activities.list().find(row => row.correlation?.jobId === job!.id))
+ ctx.jobs.kill(job!.id, undefined, 'test cleanup')
+ await until(() => ctx.activities.get(snapshot.id).status === 'killed' ? true : undefined)
+ expect(ctx.activities.get(snapshot.id).detail).toMatch(/signal|killed before exit/)
+ })
+
+ it('labels stderr chunks with their channel', async () => {
+ const ctx = await setup()
+ await call(ctx, {
+ command: 'echo out-line; echo err-line 1>&2',
+ description: 'test command',
+ run_in_background: true,
+ })
+ const job = ctx.jobs.list()[0]
+ const snapshot = await until(() =>
+ ctx.activities.list().find(row => row.correlation?.jobId === job!.id))
+ await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined)
+ const chunks = ctx.activities.read(snapshot.id, 0).chunks
+ expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout')
+ expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr')
+ })
+
+ it('a registry whose handle rejects appends degrades to a warning and still settles', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(LocalJobRegistry)
+ await ctx.plugin(ToolTasks)
+ const ended: unknown[] = []
+ await ctx.plugin({
+ name: 'appending-broken-activities-probe',
+ apply(child: Context) {
+ child.provide('activities', {
+ open: () => ({
+ id: 'bash-1',
+ append() { throw new Error('append boom') },
+ updateDetail() {},
+ end(outcome: unknown) { ended.push(outcome) },
+ }),
+ })
+ },
+ })
+ await ctx.plugin(LocalSubprocessRuntime)
+ ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
+ await ctx.plugin(BashEnvPlugin)
+ await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
+ const warn = vi.fn()
+ ctx.logger.warn = warn as never
+ await ctx.plugin(ToolBash, { activityPollMs: 25 })
+ await call(ctx, { command: 'echo tolerated', description: 'test command', run_in_background: true })
+ await until(() => ended.length > 0 ? true : undefined)
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation pump'))
+ expect(ended[0]).toMatchObject({ status: 'completed' })
+ })
+
+ it('a throwing observation registry never breaks the background call', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(LocalJobRegistry)
+ await ctx.plugin(ToolTasks)
+ await ctx.plugin({
+ name: 'broken-activities-probe',
+ apply(child: Context) {
+ child.provide('activities', { open() { throw new Error('observation boom') } })
+ },
+ })
+ await ctx.plugin(LocalSubprocessRuntime)
+ ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir }
+ await ctx.plugin(BashEnvPlugin)
+ await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
+ const warn = vi.fn()
+ ctx.logger.warn = warn as never
+ await ctx.plugin(ToolBash, { activityPollMs: 25 })
+ const ack = await call(ctx, { command: 'echo resilient', description: 'test command', run_in_background: true })
+ expect(text(ack)).toContain('started background job')
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable'))
+ // The job itself still completes and reads normally.
+ const job = ctx.jobs.list()[0]
+ const read = await until(() => {
+ const value = ctx.jobs.read(job!.id)
+ return value.text.includes('resilient') ? value : undefined
+ })
+ expect(read.snapshot.status).toBe('completed')
+ })
+})
diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json
index 304d983a95..8aa66f93ff 100644
--- a/packages/shell/tool-bash/tsconfig.json
+++ b/packages/shell/tool-bash/tsconfig.json
@@ -49,6 +49,9 @@
},
{
"path": "../../sandbox/sandbox-policy"
+ },
+ {
+ "path": "../../activity/activity"
}
]
}
diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml
index c5dcb85005..0a88134fb3 100644
--- a/packages/shell/tool-pwsh/README.i18n.yaml
+++ b/packages/shell/tool-pwsh/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/shell/tool-pwsh/README.md
-README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716
-README.zh.md: 0a66b119ac2bf0386c0686e9e22ddfe1c3bb87b1
+README.md: 5ebc22e8607337c2698756891cce2b9ba9e06184
+README.zh.md: 2e5186383babadab9ea894b3cb6364ab8bc23e6b
diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md
index e862fcf0ca..5ebc22e860 100644
--- a/packages/shell/tool-pwsh/README.md
+++ b/packages/shell/tool-pwsh/README.md
@@ -34,7 +34,7 @@ Result text contains stdout, an optional `[stderr]` section, then applicable tru
The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', jobId }` for a published task. The renderer preserves exactly `started background job ` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
-When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
+When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort as a `pwsh` activity pumped at `activityPollMs` (default 150), exactly like `dsh-tool-bash`.
## UI presentation
diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md
index 0a66b119ac..2e5186383b 100644
--- a/packages/shell/tool-pwsh/README.zh.md
+++ b/packages/shell/tool-pwsh/README.zh.md
@@ -34,7 +34,7 @@
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...ShellRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', jobId }`。渲染器对后台 ack 精确保留 `started background job `;编程消费者使用类型化字段而不解析渲染文本。
-当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner,并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。
+当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner,并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。当可选的 `ctx.activities` 注册表已加载时,已提交的后台运行还会尽力镜像为一个 `pwsh` activity,以 `activityPollMs`(默认 150)的节奏泵取,与 `dsh-tool-bash` 完全一致。
## UI presentation
diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json
index 2294a955a3..3156508e39 100644
--- a/packages/shell/tool-pwsh/package.json
+++ b/packages/shell/tool-pwsh/package.json
@@ -32,39 +32,41 @@
],
"license": "MIT",
"peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
- "@deepseek-ai/dsh-shell": "workspace:^",
- "@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+ "@deepseek-ai/dsh-shell": "workspace:^",
+ "@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/dsh-user-approval": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/dsh-user-approval": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
- "@deepseek-ai/dsh-shell": "workspace:^",
- "@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-jobs": "workspace:^",
+ "@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
+ "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+ "@deepseek-ai/dsh-shell": "workspace:^",
+ "@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/dsh-jobs": "workspace:^",
- "@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/dsh-user-approval": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/dsh-user-approval": "workspace:^"
}
}
diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts
index 603322e381..f3e63551e5 100644
--- a/packages/shell/tool-pwsh/src/index.ts
+++ b/packages/shell/tool-pwsh/src/index.ts
@@ -33,9 +33,11 @@ import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
-import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
+import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import { parseExitStatus } from '@deepseek-ai/dsh-shell'
+import type {} from '@deepseek-ai/dsh-activity'
import { processOutcome } from './background.ts'
+import { observeBackgroundActivity } from './observe.ts'
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
import type { RenderablePwshResult } from './render.ts'
@@ -45,19 +47,34 @@ declare module '@deepseek-ai/dsh-jobs' {
}
}
+declare module '@deepseek-ai/dsh-activity' {
+ interface ActivityKindMap {
+ pwsh: 'pwsh'
+ }
+}
+
export const name = 'tool-pwsh'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
+/* jscpd:ignore-start -- the pwsh Config mirrors tool-bash's by design, like render/background. */
/** Configuration for the pwsh tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
+ /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */
+ activityPollMs?: number
}
/** Runtime configuration schema for the pwsh tool plugin. */
export const Config: z = z.object({
enableRunInBackground: z.boolean().default(true),
+ activityPollMs: z.number()
+ .step(1)
+ .min(1)
+ .max(Number.MAX_SAFE_INTEGER)
+ .default(150),
})
+/* jscpd:ignore-end */
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
interface PwshToolArgs {
@@ -194,6 +211,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */
export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
+ const activityPollMs = config.activityPollMs ?? 150
const defaultMode = ctx.shell.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
@@ -378,12 +396,14 @@ export function apply(ctx: Context, config: Config = {}): void {
throw error
}
// Task preflight finishes before the starter can spawn a process.
+ let started: ShellProcess | undefined
const id = jobs.start({
kind: 'pwsh',
label: args.command,
...exec.agent ? { owner: exec.agent } : {},
run: () => {
const proc = ctx.shell.start(ctx.shell.resolve(request))
+ started = proc
return {
cancel: () => void proc.kill(),
done: proc.done.then(() => processOutcome(proc)),
@@ -391,6 +411,16 @@ export function apply(ctx: Context, config: Config = {}): void {
}
},
})
+ // The starter ran synchronously inside the committed start; mirror the
+ // spawned process into the optional observation registry.
+ if (started !== undefined) {
+ observeBackgroundActivity(ctx, started, {
+ kind: 'pwsh',
+ label: args.command,
+ ...exec.agent ? { owner: exec.agent } : {},
+ correlation: { callId: exec.callId, jobId: id },
+ }, activityPollMs, processOutcome)
+ }
return { kind: 'background' as const, jobId: id }
}
const result = await ctx.shell.run(ctx.shell.resolve({
diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts
new file mode 100644
index 0000000000..6d342dda8b
--- /dev/null
+++ b/packages/shell/tool-pwsh/src/observe.ts
@@ -0,0 +1,74 @@
+/**
+ * Best-effort process-observation tap for background pwsh runs: mirrors the
+ * spawned process's captured streams into `ctx.activities` so non-consuming
+ * observers (the Web client) see live output without touching the job's
+ * consuming `readOutput` cursor. Absent registry or absent backend offset
+ * readers degrade to no observation; the job path is unaffected either way.
+ *
+ * @module @deepseek-ai/dsh-tool-pwsh/observe
+ */
+
+/* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ShellProcess } from '@deepseek-ai/dsh-shell'
+import { pumpActivityOutput } from '@deepseek-ai/dsh-activity'
+import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity'
+
+/** Identity of one observed background run. */
+export interface ObserveBackgroundSpec {
+ /** Process kind — the producing tool's registered {@link ActivityKind}. */
+ kind: ActivityKind
+ /** One-line label (the command). */
+ label: string
+ /** Owning live agent, when the call has one. */
+ owner?: Agent
+ /** Links to the starting tool call and the registered job. */
+ correlation: ActivityCorrelation
+}
+
+/**
+ * Open a process beside a committed background job and pump the handle's
+ * non-consuming offset readers into it until the run settles, then record the
+ * mapped outcome. Observation is strictly best-effort: without a
+ * `ctx.activities` registry this is a no-op, without backend offset readers
+ * the process still carries status and settlement, and ANY observation
+ * failure — including a rejected `open()` — is logged and swallowed, because
+ * the background job is already committed and running.
+ * @param ctx - plugin context used for the optional registry and warning logs.
+ * @param proc - the started background process handle.
+ * @param spec - process identity and correlation for the registry row.
+ * @param pollMs - producer-configured pump cadence in milliseconds.
+ * @param outcome - maps the settled handle to the process outcome.
+ */
+export function observeBackgroundActivity(
+ ctx: Context,
+ proc: ShellProcess,
+ spec: ObserveBackgroundSpec,
+ pollMs: number,
+ outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string },
+): void {
+ try {
+ const processes = ctx.get('activities')
+ if (processes === undefined) return
+ const handle = processes.open({
+ kind: spec.kind,
+ label: spec.label,
+ ...spec.owner !== undefined ? { owner: spec.owner } : {},
+ correlation: spec.correlation,
+ })
+ const sources: ActivityPumpSource[] = []
+ const stdout = proc.observed?.stdout
+ if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) })
+ const stderr = proc.observed?.stderr
+ if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) })
+ void pumpActivityOutput(handle, sources, { pollMs, done: proc.done })
+ .catch((error: unknown) => {
+ ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`)
+ })
+ .then(() => { handle.end(outcome(proc)) })
+ } catch (error: unknown) {
+ ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`)
+ }
+}
+/* jscpd:ignore-end */
diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts
new file mode 100644
index 0000000000..25b5bbfbd4
--- /dev/null
+++ b/packages/shell/tool-pwsh/tests/observe.spec.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
+import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
+import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
+import { ShellExecutor } from '@deepseek-ai/dsh-shell'
+import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
+import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
+import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
+
+const testToolSignal = new AbortController().signal
+
+/** ASCII-only scripted stream: readFrom offsets are byte-exact string indexes. */
+function scriptedReader(state: { text: string }) {
+ return {
+ readFrom(fromByte: number) {
+ return { text: state.text.slice(fromByte), nextOffset: state.text.length, lossy: false }
+ },
+ }
+}
+
+/** A running fake background handle with optional non-consuming observed streams. */
+function observableProcess(streams?: { stdout: { text: string }; stderr: { text: string } }) {
+ let resolveDone: () => void = () => {}
+ const done = new Promise((resolve) => { resolveDone = resolve })
+ const proc: ShellProcess = {
+ status: 'running',
+ exitCode: null,
+ signal: null,
+ done,
+ readOutput: () => ({ delta: '', lossy: false }),
+ kill: () => false,
+ ...streams !== undefined
+ ? { observed: { stdout: scriptedReader(streams.stdout), stderr: scriptedReader(streams.stderr) } }
+ : {},
+ }
+ return {
+ proc,
+ finish() {
+ proc.status = 'completed'
+ proc.exitCode = 0
+ resolveDone()
+ },
+ }
+}
+
+class FakePwsh extends ShellExecutor {
+ backgroundHandler: (spec: ShellExecSpec) => ShellProcess = () => { throw new Error('unscripted start') }
+
+ override resolve(request: ShellExecRequest): ShellExecSpec {
+ return {
+ command: request.command,
+ workdir: request.workdir ?? process.cwd(),
+ timeoutMs: request.timeoutMs ?? 60_000,
+ stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
+ ...request.signal ? { signal: request.signal } : {},
+ ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
+ sandboxPolicy: request.sandboxPolicy,
+ }
+ }
+
+ override async run(): Promise {
+ throw new Error('foreground is not exercised here')
+ }
+
+ override start(spec: ShellExecSpec): ShellProcess {
+ return this.backgroundHandler(spec)
+ }
+}
+
+async function setup() {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(LocalJobRegistry)
+ await ctx.plugin(ToolTasks)
+ await ctx.plugin(LocalActivityRegistry)
+ await ctx.plugin(BashEnvPlugin)
+ await ctx.plugin(FakePwsh)
+ await ctx.plugin(ToolPwsh, { activityPollMs: 5 })
+ return { ctx, pwsh: ctx.shell as FakePwsh }
+}
+
+let callCounter = 0
+function call(ctx: Context, args: Record) {
+ return ctx.tools.execute({
+ signal: testToolSignal,
+ callId: CallId(`pwsh-observe-${++callCounter}`),
+ name: 'pwsh',
+ arguments: args,
+ })
+}
+
+async function until(read: () => T | undefined, timeoutMs = 5_000): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ const value = read()
+ if (value !== undefined) return value
+ await new Promise(resolve => setTimeout(resolve, 5))
+ }
+ throw new Error('condition not reached before timeout')
+}
+
+describe('background pwsh observation', () => {
+ it('mirrors observed streams with channels and settles with the mapped outcome', async () => {
+ const { ctx, pwsh } = await setup()
+ const stdout = { text: '' }
+ const stderr = { text: '' }
+ const scripted = observableProcess({ stdout, stderr })
+ pwsh.backgroundHandler = () => scripted.proc
+
+ await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true })
+ const job = ctx.jobs.list()[0]
+ const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id))
+ expect(row.kind).toBe('pwsh')
+
+ stdout.text = 'progress-line\n'
+ stderr.text = 'warn-line\n'
+ await until(() => {
+ const chunks = ctx.activities.read(row.id, 0).chunks
+ return chunks.some(chunk => chunk.text.includes('progress-line'))
+ && chunks.some(chunk => chunk.text.includes('warn-line'))
+ ? true
+ : undefined
+ })
+ const chunks = ctx.activities.read(row.id, 0).chunks
+ expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout')
+ expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr')
+
+ scripted.finish()
+ await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined)
+ expect(ctx.activities.get(row.id).detail).toBe('exit code: 0')
+ })
+
+ it('a backend without observed readers still yields a status-only process', async () => {
+ const { ctx, pwsh } = await setup()
+ const scripted = observableProcess()
+ pwsh.backgroundHandler = () => scripted.proc
+
+ await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true })
+ const job = ctx.jobs.list()[0]
+ const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id))
+ expect(ctx.activities.read(row.id, 0).chunks).toEqual([])
+
+ scripted.finish()
+ await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined)
+ expect(ctx.activities.get(row.id).outputTotal).toBe(0)
+ })
+})
diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json
index 59a4d9478c..d38c8f031d 100644
--- a/packages/shell/tool-pwsh/tsconfig.json
+++ b/packages/shell/tool-pwsh/tsconfig.json
@@ -52,6 +52,9 @@
},
{
"path": "../../runtime-diagnostics/invariants"
+ },
+ {
+ "path": "../../activity/activity"
}
]
}
diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml
index f87a12407f..65a8c97d7b 100644
--- a/packages/workflow/tool-workflow/README.i18n.yaml
+++ b/packages/workflow/tool-workflow/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/workflow/tool-workflow/README.md
-README.md: 918fe5b9aff74b3b959107ec1eebfd7966afe6d1
-README.zh.md: 2546a0fc92ef39d71949cc0061dcf363ecc2e22d
+README.md: 29e84c7386768b77c7b85ed8426f6f604a53fb44
+README.zh.md: 582828cf7c2d5b6c6e7dca6230d29fe744b578fe
diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md
index 918fe5b9af..29e84c7386 100644
--- a/packages/workflow/tool-workflow/README.md
+++ b/packages/workflow/tool-workflow/README.md
@@ -14,7 +14,7 @@ Collection is synchronous (like [`dsh-tool-subagent`](../../subagent/tool-subage
For a root transport execution (`exec.parent` absent), the tool also projects the run into the calling Agent's Session: run-start after `start()` returns, matching member starts and endings filtered by `run.id`, then run-end only after `run.result` is available and `dispose()` has reached quiescence. Nested transport calls execute normally but write no workflow record. The first failed Session append disables later recording for that run, emits one warning, and leaves either no record or a legal continuous prefix without changing the tool result or cleanup.
-The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes.
+The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. Beside the durable record, each recorded top-level run is mirrored best-effort into the optional `ctx.activities` registry as a `workflow` activity: the otherwise-unconsumed `workflow/phase` and `workflow/log` events plus member lifecycle lines stream into it, so the Web client shows the run's live narration the session log deliberately does not carry per line; without the registry, or on any mirror failure, the run is unaffected. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes.
## Render intent
diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md
index 2546a0fc92..582828cf7c 100644
--- a/packages/workflow/tool-workflow/README.zh.md
+++ b/packages/workflow/tool-workflow/README.zh.md
@@ -14,7 +14,7 @@
对于根 transport 执行(`exec.parent` 缺省),工具还会把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在 `run.result` 已取得且 `dispose()` 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写工作流记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。
-浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。
+浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。在持久记录之外,每个被记录的顶层运行还会尽力镜像进可选的 `ctx.activities` 注册表成为一个 `workflow` activity:原本无人消费的 `workflow/phase` 与 `workflow/log` 事件加上成员生命周期行会流入其中,Web 客户端因此能看到会话日志有意不逐行携带的实时叙述;没有该注册表或镜像任何失败时,运行不受影响。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。
## 渲染意图
diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json
index 455d2ee54c..6cf4bebe3d 100644
--- a/packages/workflow/tool-workflow/package.json
+++ b/packages/workflow/tool-workflow/package.json
@@ -37,19 +37,23 @@
],
"license": "MIT",
"peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/dsh-workflow": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/dsh-workflow": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-activity": "workspace:^",
+ "@deepseek-ai/dsh-activity-local": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -58,7 +62,6 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
- "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^"
}
}
diff --git a/packages/workflow/tool-workflow/src/activity.ts b/packages/workflow/tool-workflow/src/activity.ts
new file mode 100644
index 0000000000..36b251b894
--- /dev/null
+++ b/packages/workflow/tool-workflow/src/activity.ts
@@ -0,0 +1,119 @@
+/**
+ * Best-effort activity mirror for top-level workflow runs: opens one
+ * `workflow` activity per run and streams the engine's `workflow/phase`,
+ * `workflow/log`, and member lifecycle events into it as text lines, so the
+ * Web client shows live progress the session log deliberately does not carry
+ * per line. Without a `ctx.activities` registry every operation is a no-op,
+ * and ANY mirror failure is logged and swallowed — the run is unaffected.
+ *
+ * @module @deepseek-ai/dsh-tool-workflow/activity
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ActivityHandle } from '@deepseek-ai/dsh-activity'
+import type { WorkflowRun, WorkflowRunId, WorkflowStopReason } from '@deepseek-ai/dsh-workflow'
+
+declare module '@deepseek-ai/dsh-activity' {
+ interface ActivityKindMap {
+ workflow: 'workflow'
+ }
+}
+
+/** Activity taps for the three points the tool already tracks a run at. */
+export interface WorkflowActivityMirror {
+ /**
+ * Open the run's activity. Call once per recorded top-level run.
+ * @param run - the started run (id and meta name).
+ * @param owner - the calling agent, when the call has one.
+ */
+ start(run: WorkflowRun, owner: Agent | undefined): void
+ /**
+ * End the run's activity with the mapped stop reason.
+ * @param runId - the settled run.
+ * @param stopReason - how the run stopped.
+ */
+ finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void
+ /**
+ * Drop tracking for a run that ends without a settled reason. First-wins
+ * with {@link finish}: after a normal finish this is inert.
+ * @param runId - the abandoned run.
+ */
+ abandon(runId: WorkflowRunId): void
+}
+
+/** Map a run's stop reason onto the activity outcome vocabulary. */
+function outcomeOf(stopReason: WorkflowStopReason): { status: 'completed' | 'failed' | 'killed'; detail: string } {
+ switch (stopReason) {
+ case 'completed': return { status: 'completed', detail: 'completed' }
+ case 'cancelled': return { status: 'killed', detail: 'cancelled' }
+ case 'error': return { status: 'failed', detail: 'error' }
+ }
+}
+
+/**
+ * Create the run-to-activity mirror and subscribe the engine's live progress
+ * events for the runs it tracks.
+ * @param ctx - plugin context used for the optional registry and warning logs.
+ * @returns the mirror taps the tool wires beside its durable recorder.
+ */
+export function createWorkflowActivityMirror(ctx: Context): WorkflowActivityMirror {
+ const active = new Map()
+ const contained = (operation: string, act: () => void): void => {
+ try {
+ act()
+ } catch (error: unknown) {
+ ctx.logger.warn(`workflow activity mirror ${operation} failed: ${String(error)}`)
+ }
+ }
+
+ ctx.on('workflow/phase', (info, title) => {
+ const handle = active.get(info.id)
+ if (handle === undefined) return
+ contained('phase', () => {
+ handle.updateDetail(title)
+ handle.append(`▸ ${title}\n`)
+ })
+ })
+ ctx.on('workflow/log', (info, message) => {
+ const handle = active.get(info.id)
+ if (handle === undefined) return
+ contained('log', () => { handle.append(`${message}\n`) })
+ })
+ ctx.on('workflow/agent-start', (info, agent) => {
+ const handle = active.get(info.id)
+ if (handle === undefined) return
+ contained('agent-start', () => { handle.append(`agent #${agent.seq} ${agent.label} started\n`) })
+ })
+ ctx.on('workflow/agent-end', (info, agent) => {
+ const handle = active.get(info.id)
+ if (handle === undefined) return
+ contained('agent-end', () => { handle.append(`agent #${agent.seq} ${agent.outcome}\n`) })
+ })
+
+ return {
+ start(run, owner) {
+ contained('open', () => {
+ const activities = ctx.get('activities')
+ if (activities === undefined) return
+ active.set(run.id, activities.open({
+ kind: 'workflow',
+ label: run.meta.name,
+ ...owner !== undefined ? { owner } : {},
+ }))
+ })
+ },
+ finish(runId, stopReason) {
+ const handle = active.get(runId)
+ active.delete(runId)
+ if (handle === undefined) return
+ contained('finish', () => { handle.end(outcomeOf(stopReason)) })
+ },
+ abandon(runId) {
+ const handle = active.get(runId)
+ active.delete(runId)
+ if (handle === undefined) return
+ contained('abandon', () => { handle.end({ status: 'killed', detail: 'run abandoned' }) })
+ },
+ }
+}
diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts
index 446c70ce03..0da8c0ff83 100644
--- a/packages/workflow/tool-workflow/src/index.ts
+++ b/packages/workflow/tool-workflow/src/index.ts
@@ -25,6 +25,7 @@ import type {
} from './types.ts'
// Declaration merge only: makes ctx.systemPrompt visible for the section registration.
import type {} from '@deepseek-ai/dsh-system-prompt'
+import { createWorkflowActivityMirror } from './activity.ts'
export const name = 'tool-workflow'
export const inject = ['tools', 'workflowEngine', 'systemPrompt']
@@ -207,6 +208,7 @@ export function apply(ctx: Context, config: Config): void {
// fields; the assertion records that resolution, not a hidden fallback.
const { toolName, maxResultChars } = config as ResolvedConfig
const recorder = createWorkflowRecorder(ctx)
+ const mirror = createWorkflowActivityMirror(ctx)
// Usage policy ships with the tool (the master convention: tool guidance
// lives in tool plugins as prompt sections, not in the deployment persona).
ctx.systemPrompt.section({
@@ -291,7 +293,10 @@ export function apply(ctx: Context, config: Config): void {
const recordsRun = exec.parent === undefined
// The shipped worker-thread engine publishes member events from later
// worker messages, after start() returns and this run record is active.
- if (recordsRun) recorder.start(parent.session, run)
+ if (recordsRun) {
+ recorder.start(parent.session, run)
+ mirror.start(run, parent)
+ }
// Bridge the tool's abort signal to the run: if the parent step is aborted while the
// script is in flight, cancel the whole run. The signal also enters the engine directly, but
@@ -323,9 +328,13 @@ export function apply(ctx: Context, config: Config): void {
/* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */
if (result === undefined) throw new Error('workflow run settled without a result')
recorder.finish(run.id, result.stopReason)
+ mirror.finish(run.id, result.stopReason)
}
} finally {
- if (recordsRun) recorder.abandon(run.id)
+ if (recordsRun) {
+ recorder.abandon(run.id)
+ mirror.abandon(run.id)
+ }
}
}
},
diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts
new file mode 100644
index 0000000000..50788d453f
--- /dev/null
+++ b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
+import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
+import { createWorkflowActivityMirror } from '../src/activity.ts'
+
+function runStub(id = 'run-1'): { run: WorkflowRun; info: WorkflowRunInfo } {
+ const info = { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRunInfo
+ return {
+ run: { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRun,
+ info,
+ }
+}
+
+function retainedText(ctx: Context): string {
+ const row = ctx.activities.list()[0]
+ if (row === undefined) throw new Error('no mirrored activity')
+ return ctx.activities.read(row.id, 0).chunks.map(chunk => chunk.text).join('')
+}
+
+describe('workflow activity mirror', () => {
+ it('opens one workflow activity per run and streams phase, log, and member lines', async () => {
+ const ctx = new Context()
+ await ctx.plugin(LocalActivityRegistry)
+ const mirror = createWorkflowActivityMirror(ctx)
+ const { run, info } = runStub()
+ mirror.start(run, undefined)
+
+ const row = ctx.activities.list()[0]
+ expect(row?.kind).toBe('workflow')
+ expect(row?.label).toBe('docs-sweep')
+
+ ctx.emit('workflow/phase', info, 'Scan')
+ ctx.emit('workflow/log', info, '3 files queued')
+ ctx.emit('workflow/agent-start', info, { seq: 1, label: 'reader', childId: 'c1' } as never)
+ ctx.emit('workflow/agent-end', info, { seq: 1, label: 'reader', childId: 'c1', outcome: 'completed' } as never)
+ expect(ctx.activities.get(row!.id).detail).toBe('Scan')
+ expect(retainedText(ctx)).toBe('▸ Scan\n3 files queued\nagent #1 reader started\nagent #1 completed\n')
+
+ mirror.finish(run.id, 'completed')
+ const settled = ctx.activities.get(row!.id)
+ expect(settled.status).toBe('completed')
+ // The always-run abandon after a normal finish is inert.
+ mirror.abandon(run.id)
+ expect(ctx.activities.get(row!.id).status).toBe('completed')
+ })
+
+ it('maps cancelled and error stops, and ends an abandoned run as killed', async () => {
+ const ctx = new Context()
+ await ctx.plugin(LocalActivityRegistry)
+ const mirror = createWorkflowActivityMirror(ctx)
+
+ const cancelled = runStub('run-c')
+ mirror.start(cancelled.run, undefined)
+ mirror.finish(cancelled.run.id, 'cancelled')
+
+ const failed = runStub('run-e')
+ mirror.start(failed.run, undefined)
+ mirror.finish(failed.run.id, 'error')
+
+ const orphaned = runStub('run-o')
+ mirror.start(orphaned.run, undefined)
+ mirror.abandon(orphaned.run.id)
+
+ const byLabelOrder = ctx.activities.list()
+ expect(byLabelOrder.map(row => [row.status, row.detail])).toEqual([
+ ['killed', 'cancelled'],
+ ['failed', 'error'],
+ ['killed', 'run abandoned'],
+ ])
+ })
+
+ it('ignores events for untracked runs and stays inert without the registry', async () => {
+ const bare = new Context()
+ const inert = createWorkflowActivityMirror(bare)
+ const { run, info } = runStub()
+ // No registry: every tap is a no-op rather than a throw.
+ inert.start(run, undefined)
+ bare.emit('workflow/log', info, 'dropped')
+ inert.finish(run.id, 'completed')
+ inert.abandon(run.id)
+
+ const ctx = new Context()
+ await ctx.plugin(LocalActivityRegistry)
+ createWorkflowActivityMirror(ctx)
+ // Events for a run the mirror never opened change nothing.
+ ctx.emit('workflow/phase', info, 'ghost')
+ expect(ctx.activities.list()).toEqual([])
+ })
+
+ it('contains a throwing registry so the run path never breaks', async () => {
+ const ctx = new Context()
+ const warn = vi.fn()
+ ctx.logger.warn = warn as never
+ await ctx.plugin({
+ name: 'broken-activities-probe',
+ apply(child: Context) {
+ child.provide('activities', { open() { throw new Error('mirror boom') } })
+ },
+ })
+ const mirror = createWorkflowActivityMirror(ctx)
+ const { run } = runStub()
+ expect(() => { mirror.start(run, undefined) }).not.toThrow()
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('workflow activity mirror open failed'))
+ })
+})
diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json
index bfd38e61d4..a4cde5cbef 100644
--- a/packages/workflow/tool-workflow/tsconfig.json
+++ b/packages/workflow/tool-workflow/tsconfig.json
@@ -37,6 +37,9 @@
},
{
"path": "../../runtime-diagnostics/invariants"
+ },
+ {
+ "path": "../../activity/activity"
}
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6878a775b..32f9a464b7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -886,6 +886,110 @@ importers:
specifier: workspace:^
version: link:../../interaction/user-approval
+ packages/activity/activity:
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-brand':
+ specifier: workspace:^
+ version: link:../../util/brand
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../runtime-diagnostics/invariants
+ '@deepseek-ai/dsh-jobs':
+ specifier: workspace:^
+ version: link:../../jobs/jobs
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+
+ packages/activity/activity-local:
+ dependencies:
+ '@deepseek-ai/schemastery':
+ specifier: link:../../../vendor/schemastery
+ version: link:../../../vendor/schemastery
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/cordis-plugin-include':
+ specifier: workspace:^
+ version: link:../../../vendor/include
+ '@deepseek-ai/cordis-plugin-loader':
+ specifier: workspace:^
+ version: link:../../../vendor/loader
+ '@deepseek-ai/dsh-activity':
+ specifier: workspace:^
+ version: link:../activity
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-brand':
+ specifier: workspace:^
+ version: link:../../util/brand
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../runtime-diagnostics/invariants
+ '@deepseek-ai/dsh-scope':
+ specifier: workspace:^
+ version: link:../../core/scope
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+
+ packages/api/activity-controller:
+ dependencies:
+ '@deepseek-ai/schemastery':
+ specifier: link:../../../vendor/schemastery
+ version: link:../../../vendor/schemastery
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-activity':
+ specifier: workspace:^
+ version: link:../../activity/activity
+ '@deepseek-ai/dsh-activity-local':
+ specifier: workspace:^
+ version: link:../../activity/activity-local
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-api-gateway':
+ specifier: workspace:^
+ version: link:../gateway
+ '@deepseek-ai/dsh-client-connection':
+ specifier: workspace:^
+ version: link:../../client/connection
+ '@deepseek-ai/dsh-client-store':
+ specifier: workspace:^
+ version: link:../../client/store
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../runtime-diagnostics/invariants
+ '@deepseek-ai/dsh-jobs':
+ specifier: workspace:^
+ version: link:../../jobs/jobs
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-typert-protocol':
+ specifier: workspace:^
+ version: link:../../typert/protocol
+
packages/api/gateway:
dependencies:
'@deepseek-ai/dsh-typert-protocol':
@@ -938,6 +1042,9 @@ importers:
'@deepseek-ai/dsh-agent-presets':
specifier: workspace:^
version: link:../../preset/agent-presets
+ '@deepseek-ai/dsh-api-activity-controller':
+ specifier: workspace:^
+ version: link:../activity-controller
'@deepseek-ai/dsh-api-gateway':
specifier: workspace:^
version: link:../gateway
@@ -1240,6 +1347,9 @@ importers:
'@deepseek-ai/cordis-plugin-timer':
specifier: workspace:^
version: link:../../../vendor/timer
+ '@deepseek-ai/dsh-activity-local':
+ specifier: workspace:^
+ version: link:../../activity/activity-local
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
@@ -1607,6 +1717,9 @@ importers:
'@deepseek-ai/dsh-agent-presets':
specifier: workspace:^
version: link:../../preset/agent-presets
+ '@deepseek-ai/dsh-api-activity-controller':
+ specifier: workspace:^
+ version: link:../../api/activity-controller
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
@@ -1631,6 +1744,9 @@ importers:
'@deepseek-ai/dsh-client-modules':
specifier: workspace:^
version: link:../../client/modules
+ '@deepseek-ai/dsh-client-ui-activity':
+ specifier: workspace:^
+ version: link:../../client/ui-activity
'@deepseek-ai/dsh-client-ui-agent-preset':
specifier: workspace:^
version: link:../../client/ui-agent-preset
@@ -1976,6 +2092,45 @@ importers:
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
+ packages/client/ui-activity:
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-api-activity-controller':
+ specifier: workspace:^
+ version: link:../../api/activity-controller
+ '@deepseek-ai/dsh-client-locale':
+ specifier: workspace:^
+ version: link:../locale
+ '@deepseek-ai/dsh-client-test-runtime':
+ specifier: workspace:^
+ version: link:../../test-support/client-runtime
+ '@deepseek-ai/dsh-client-ui-conversation':
+ specifier: workspace:^
+ version: link:../ui-conversation
+ '@deepseek-ai/dsh-client-ui-primitives':
+ specifier: workspace:^
+ version: link:../ui-primitives
+ '@deepseek-ai/dsh-client-ui-renderer':
+ specifier: workspace:^
+ version: link:../ui-renderer
+ '@deepseek-ai/dsh-client-ui-session':
+ specifier: workspace:^
+ version: link:../ui-session
+ '@deepseek-ai/dsh-client-ui-slots':
+ specifier: workspace:^
+ version: link:../ui-slots
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../runtime-diagnostics/invariants
+ '@types/react':
+ specifier: ~18.3.1
+ version: 18.3.31
+ react:
+ specifier: ^18.2.0
+ version: 18.3.1
+
packages/client/ui-agent-preset:
devDependencies:
'@deepseek-ai/cordis':
@@ -7701,6 +7856,9 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-activity':
+ specifier: workspace:^
+ version: link:../../activity/activity
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
@@ -7820,6 +7978,9 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-activity':
+ specifier: workspace:^
+ version: link:../../activity/activity
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
@@ -9538,6 +9699,12 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-activity':
+ specifier: workspace:^
+ version: link:../../activity/activity
+ '@deepseek-ai/dsh-activity-local':
+ specifier: workspace:^
+ version: link:../../activity/activity-local
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
@@ -9690,6 +9857,9 @@ importers:
'@deepseek-ai/dsh-acp':
specifier: workspace:^
version: link:../../packages/acp/acp
+ '@deepseek-ai/dsh-activity':
+ specifier: workspace:^
+ version: link:../../packages/activity/activity
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../packages/core/agent
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index d5684867e6..3becddb8c7 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -5,6 +5,7 @@
"private": true,
"type": "module",
"dependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-group": "workspace:^",
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
@@ -12,15 +13,17 @@
"@deepseek-ai/cosmokit": "workspace:^",
"@deepseek-ai/dsh": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
+ "@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-agent-instructions": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-agent-tool-presentation": "workspace:^",
+ "@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
- "@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-atomic-write": "workspace:^",
- "@deepseek-ai/dsh-shell": "workspace:^",
- "@deepseek-ai/dsh-shell-env": "workspace:^",
+ "@deepseek-ai/dsh-attachment": "workspace:^",
+ "@deepseek-ai/dsh-authorization": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
@@ -32,44 +35,43 @@
"@deepseek-ai/dsh-compaction-basic": "workspace:^",
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
- "@deepseek-ai/dsh-authorization": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
- "@deepseek-ai/dsh-launch-environment": "workspace:^",
+ "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-observation-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-round-driver": "workspace:^",
+ "@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-hooks-claude-code": "workspace:^",
"@deepseek-ai/dsh-hooks-codex": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
- "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^",
+ "@deepseek-ai/dsh-jobs": "workspace:^",
+ "@deepseek-ai/dsh-jobs-local": "workspace:^",
+ "@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
- "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^",
- "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^",
- "@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
- "@deepseek-ai/dsh-home-paths": "workspace:^",
- "@deepseek-ai/dsh-permission-presets": "workspace:^",
- "@deepseek-ai/dsh-plan-mode": "workspace:^",
- "@deepseek-ai/dsh-persona": "workspace:^",
- "@deepseek-ai/dsh-pwsh-local": "workspace:^",
- "@deepseek-ai/dsh-terminal": "workspace:^",
- "@deepseek-ai/dsh-terminal-bash": "workspace:^",
- "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^",
"@deepseek-ai/dsh-output-retention": "workspace:^",
+ "@deepseek-ai/dsh-permission-presets": "workspace:^",
+ "@deepseek-ai/dsh-persona": "workspace:^",
+ "@deepseek-ai/dsh-plan-mode": "workspace:^",
+ "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^",
+ "@deepseek-ai/dsh-pwsh-local": "workspace:^",
+ "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
+ "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
+ "@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
@@ -80,6 +82,8 @@
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
+ "@deepseek-ai/dsh-shell": "workspace:^",
+ "@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-filesystem": "workspace:^",
"@deepseek-ai/dsh-spill": "workspace:^",
@@ -91,31 +95,30 @@
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/dsh-jobs": "workspace:^",
- "@deepseek-ai/dsh-jobs-local": "workspace:^",
+ "@deepseek-ai/dsh-terminal": "workspace:^",
+ "@deepseek-ai/dsh-terminal-bash": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
- "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
+ "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
+ "@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
- "@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
- "@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
"@deepseek-ai/dsh-user-questions": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"@deepseek-ai/dsh-web-fetch-http": "workspace:^",
@@ -124,8 +127,6 @@
"@deepseek-ai/dsh-web-search-perplexity": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
"@deepseek-ai/dsh-workflow-worker-thread": "workspace:^",
- "@deepseek-ai/dsh-agent-instructions": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
}
}
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 05a4c8947c..095c33af7c 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -105,6 +105,8 @@ export const SERVICE_PAGE: Record = {
subprocess: 'subprocess.md',
systemPrompt: 'system-prompt.md',
jobs: 'jobs.md',
+ activities: 'activity.md',
+ activityController: 'activity.md',
sessionTelemetry: 'session-telemetry.md',
agentTeams: 'agent-team.md',
tokenMeter: 'token-meter.md',
@@ -168,6 +170,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = {
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
+ activityFeed: 'client-side interface-typed browser service — packages/api/activity-controller/README.md owns the API',
}
/**
@@ -477,6 +480,16 @@ export const LINK_MAP: Readonly> = {
PromptSection: 'system-prompt.md',
SystemPrompt: 'system-prompt.md',
ToolProviderResult: 'system-prompt.md',
+ ActivitiesChangedListener: 'activity.md',
+ ActivityHandle: 'activity.md',
+ ActivityId: 'activity.md',
+ ActivityOpen: 'activity.md',
+ ActivityOutputListener: 'activity.md',
+ ActivityRead: 'activity.md',
+ ActivitySnapshot: 'activity.md',
+ ActivityControlFrame: 'activity.md',
+ ActivityObserveFrame: 'activity.md',
+ ActivityObserveRequest: 'activity.md',
JobDoneListener: 'jobs.md',
JobId: 'jobs.md',
JobRead: 'jobs.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index a47f94b943..83983f294f 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -164,6 +164,13 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core',
note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.',
},
+ {
+ key: 'activityController',
+ pkg: 'api-activity-controller',
+ title: 'Host Activity Remote controller',
+ mode: 'core',
+ note: 'Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace.',
+ },
{
key: 'invariants',
pkg: 'invariants',
@@ -537,6 +544,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'],
note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.',
},
+ {
+ key: 'activities',
+ pkg: 'activity',
+ title: 'Live activity observation registry',
+ mode: 'seam',
+ implementations: ['activity-local'],
+ consumers: ['tool-bash', 'tool-pwsh', 'tool-workflow', 'activity-controller'],
+ note: 'Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched.',
+ },
{
key: 'web',
pkg: 'web',
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index f83b621965..48bc458bfa 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -45,6 +45,10 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = {
const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' },
'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' },
+ 'packages/activity/activity': { kind: 'none', reason: 'Pure observation seam for humans; activity output becomes model-visible only through producers\' existing tool results and jobs reads.' },
+ 'packages/activity/activity-local': { kind: 'none', reason: 'In-memory observation provider; producers own every model-visible surface.' },
+ 'packages/api/activity-controller': { kind: 'none', reason: 'Browser/Host observation transport; registers no prompt, tool, or session event.' },
+ 'packages/client/ui-activity': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' },
'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' },
'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 8a8bc37baa..11ae967059 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -47,6 +47,7 @@
"@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"],
"@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"],
"@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"],
+ "@deepseek-ai/dsh-api-activity-controller": ["./packages/api/activity-controller/src/index.ts"],
"@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"],
"@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"],
"@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"],
@@ -56,6 +57,7 @@
"@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"],
"@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"],
"@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"],
+ "@deepseek-ai/dsh-api-activity-controller/types": ["./packages/api/activity-controller/src/types.ts"],
"@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"],
"@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"],
"@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"],
@@ -95,6 +97,8 @@
"@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"],
"@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"],
"@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"],
+ "@deepseek-ai/dsh-activity/brand": ["./packages/activity/activity/src/brand.ts"],
+ "@deepseek-ai/dsh-activity/invariant": ["./packages/activity/activity/src/invariant.ts"],
"@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"],
"@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"],
"@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"],
@@ -245,6 +249,7 @@
"@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"],
"@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"],
"@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"],
+ "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"],
"@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"],
"@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"],
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
@@ -288,6 +293,7 @@
"./packages/preset/*/src",
"./packages/subagent/*/src",
"./packages/jobs/*/src",
+ "./packages/activity/*/src",
"./packages/experimental/*/src",
"./packages/workflow/*/src",
"./packages/webhook/*/src",
diff --git a/tsconfig.client.json b/tsconfig.client.json
index 291b813300..41f6aea41a 100644
--- a/tsconfig.client.json
+++ b/tsconfig.client.json
@@ -56,6 +56,7 @@
{ "path": "./packages/typert/registry" },
{ "path": "./packages/api/gateway/tsconfig.client.json" },
{ "path": "./packages/api/session-controller/tsconfig.client.json" },
+ { "path": "./packages/api/activity-controller/tsconfig.client.json" },
{ "path": "./packages/api/workspace-controller/tsconfig.client.json" },
{ "path": "./packages/api/remotes/tsconfig.client.json" },
{ "path": "./packages/extensions/cordis-client-runner" },
@@ -77,6 +78,7 @@
{ "path": "./packages/client/ui-reference" },
{ "path": "./packages/client/ui-subagent" },
{ "path": "./packages/client/ui-jobs" },
+ { "path": "./packages/client/ui-activity" },
{ "path": "./packages/client/ui-directory-picker-browse" },
{ "path": "./packages/client/ui-directory-picker-native" },
{ "path": "./packages/client/ui-goal" },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 0bcbe50df3..d8bcd91c08 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -77,6 +77,7 @@
"apps/web/tests/subagent-interrupt-ui.e2e.ts",
"apps/web/tests/sidebar-subagent-activity.e2e.ts",
"apps/web/tests/background-job-list.e2e.ts",
+ "apps/web/tests/live-activity-stream.e2e.ts",
"apps/web/tests/bash-abort-row.e2e.ts",
"apps/web/tests/skill-tool-row.e2e.ts",
"apps/web/tests/turn-tail-actions.e2e.ts",
@@ -146,6 +147,7 @@
{ "path": "./packages/api/gateway/tsconfig.host.json" },
{ "path": "./packages/api/remotes/tsconfig.host.json" },
{ "path": "./packages/api/session-controller/tsconfig.host.json" },
+ { "path": "./packages/api/activity-controller/tsconfig.host.json" },
{ "path": "./packages/api/workspace-controller/tsconfig.host.json" },
{ "path": "./packages/typert/loader" },
{ "path": "./packages/session/session-persistence" },
@@ -288,6 +290,8 @@
{ "path": "./packages/jobs/jobs" },
{ "path": "./packages/jobs/jobs-local" },
{ "path": "./packages/jobs/tool-jobs" },
+ { "path": "./packages/activity/activity" },
+ { "path": "./packages/activity/activity-local" },
{ "path": "./packages/experimental/agent-team" },
{ "path": "./packages/experimental/tool-agent-team" },
{ "path": "./packages/workflow/workflow" },