dsh-working-activity 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,42 @@
1
- /**
2
- * Working-activity surface plugin, browser half: the working-line entry in
3
- * the conversation.input.dock strip. This package's node half emits
4
- * `activity/status` session events; the web runtime patch narrows them into
5
- * the conversation snapshot's `activity` member, so this dock entry reads
6
- * the live frame through the standard session kit and owns no store, no
7
- * refresh chain, and no event listener.
8
- *
9
- * Mount contract (see the root README's "Web UI 集成" section): the web
10
- * client's client-modules host scans loader entries for `dsh.client`
11
- * declarations and serves this package's `./client` bundle at
12
- * /plugins/dsh-working-activity/client.js. The entry contributes into the
13
- * input dock — no official-source patch is involved.
14
- */
15
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
16
- // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry)
17
- // through the Client assembly boundary.
18
- import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
19
- import { WorkingLine } from './WorkingLine.tsx'
20
- import type { ActivityPhase, ActivitySnapshot } from './activity.ts'
21
-
22
- export { WorkingLine, type WorkingLineProps } from './WorkingLine.tsx'
23
- export type { ActivityPhase, ActivitySnapshot } from './activity.ts'
24
-
25
- /** Required services for the dock registration. */
26
- export const inject = ['slots']
27
-
28
- /**
29
- * Client plugin body: the working-line dock entry.
30
- * @param ctx - client root context.
31
- */
32
- export function apply(ctx: ClientContext): void {
33
- ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
34
- name: 'conversation.input.dock',
35
- // The pre-slots patch used the same id in ui-conversation; entries with
36
- // equal order render in registration order, and goal (10) / queue (20)
37
- // keep their seats — this row sits between them.
38
- id: 'activity',
39
- order: 15,
40
- registrant: 'dsh-working-activity',
41
- }, WorkingLine))
42
- }
1
+ /**
2
+ * Working-activity surface plugin, browser half: the working-line entry in
3
+ * the conversation.input.dock strip. This package's node half emits
4
+ * `activity/status` session events; the web runtime patch narrows them into
5
+ * the conversation snapshot's `activity` member, so this dock entry reads
6
+ * the live frame through the standard session kit and owns no store, no
7
+ * refresh chain, and no event listener.
8
+ *
9
+ * Mount contract (see the root README's "Web UI 集成" section): the web
10
+ * client's client-modules host scans loader entries for `dsh.client`
11
+ * declarations and serves this package's `./client` bundle at
12
+ * /plugins/dsh-working-activity/client.js. The entry contributes into the
13
+ * input dock — no official-source patch is involved.
14
+ */
15
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
16
+ // Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry)
17
+ // through the Client assembly boundary.
18
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
19
+ import { WorkingLine } from './WorkingLine.tsx'
20
+ import type { ActivityPhase, ActivitySnapshot } from './activity.ts'
21
+
22
+ export { WorkingLine, type WorkingLineProps } from './WorkingLine.tsx'
23
+ export type { ActivityPhase, ActivitySnapshot } from './activity.ts'
24
+
25
+ /** Required services for the dock registration. */
26
+ export const inject = ['slots']
27
+
28
+ /**
29
+ * Client plugin body: the working-line dock entry.
30
+ * @param ctx - client root context.
31
+ */
32
+ export function apply(ctx: ClientContext): void {
33
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
34
+ name: 'conversation.input.dock',
35
+ // The pre-slots patch used the same id in ui-conversation; entries with
36
+ // equal order render in registration order, and goal (10) / queue (20)
37
+ // keep their seats — this row sits between them.
38
+ id: 'activity',
39
+ order: 15,
40
+ registrant: 'dsh-working-activity',
41
+ }, WorkingLine))
42
+ }
package/src/events.ts CHANGED
@@ -1,45 +1,45 @@
1
- /**
2
- * `activity/status` session event — a log-only, non-surface snapshot of the
3
- * model's current working activity, published by this plugin for any UI
4
- * consumer (Web client, telemetry, …). It never enters derived model history
5
- * (no `surfaceOp`), so it cannot leak into prompts; UIs render it like
6
- * `todo/write` or `plan/mode`.
7
- * @module @deepseek-ai/dsh-working-activity/events
8
- */
9
-
10
- import type { ActivityPhase } from './status.js'
11
-
12
- /** Durable payload of one `activity/status` snapshot. */
13
- export interface ActivityStatusEvent {
14
- /** Which activity phase the model is in. */
15
- readonly phase: ActivityPhase
16
- /** Human-readable status line (plain text, no ANSI). */
17
- readonly line: string
18
- /** Short label of the current work, when any. */
19
- readonly label?: string
20
- /** Detail fragment (path / command / pattern), when any. */
21
- readonly detail?: string
22
- /** The playful phrase currently shown, when the copy pool is on. */
23
- readonly phrase?: string
24
- /** Tools completed in the current turn. */
25
- readonly toolCount: number
26
- /** Milliseconds since the current turn started (0 when idle). */
27
- readonly turnElapsedMs: number
28
- /** Wall-clock time (epoch ms) the current phase started, for animations. */
29
- readonly phaseStartedAt: number
30
- }
31
-
32
- /** The `activity/status` phase vocabulary, exported for wire consumers. */
33
- export type { ActivityPhase }
34
-
35
- declare module '@deepseek-ai/dsh-session/types' {
36
- interface SessionEventMap {
37
- /**
38
- * Log-only UI snapshot of the model's working activity (thinking copy,
39
- * running tool, turn elapsed). Never a surface event: UIs render it, the
40
- * model never sees it.
41
- * @param data - The rendered status snapshot.
42
- */
43
- 'activity/status': ActivityStatusEvent
44
- }
45
- }
1
+ /**
2
+ * `activity/status` session event — a log-only, non-surface snapshot of the
3
+ * model's current working activity, published by this plugin for any UI
4
+ * consumer (Web client, telemetry, …). It never enters derived model history
5
+ * (no `surfaceOp`), so it cannot leak into prompts; UIs render it like
6
+ * `todo/write` or `plan/mode`.
7
+ * @module @deepseek-ai/dsh-working-activity/events
8
+ */
9
+
10
+ import type { ActivityPhase } from './status.js'
11
+
12
+ /** Durable payload of one `activity/status` snapshot. */
13
+ export interface ActivityStatusEvent {
14
+ /** Which activity phase the model is in. */
15
+ readonly phase: ActivityPhase
16
+ /** Human-readable status line (plain text, no ANSI). */
17
+ readonly line: string
18
+ /** Short label of the current work, when any. */
19
+ readonly label?: string
20
+ /** Detail fragment (path / command / pattern), when any. */
21
+ readonly detail?: string
22
+ /** The playful phrase currently shown, when the copy pool is on. */
23
+ readonly phrase?: string
24
+ /** Tools completed in the current turn. */
25
+ readonly toolCount: number
26
+ /** Milliseconds since the current turn started (0 when idle). */
27
+ readonly turnElapsedMs: number
28
+ /** Wall-clock time (epoch ms) the current phase started, for animations. */
29
+ readonly phaseStartedAt: number
30
+ }
31
+
32
+ /** The `activity/status` phase vocabulary, exported for wire consumers. */
33
+ export type { ActivityPhase }
34
+
35
+ declare module '@deepseek-ai/dsh-session/types' {
36
+ interface SessionEventMap {
37
+ /**
38
+ * Log-only UI snapshot of the model's working activity (thinking copy,
39
+ * running tool, turn elapsed). Never a surface event: UIs render it, the
40
+ * model never sees it.
41
+ * @param data - The rendered status snapshot.
42
+ */
43
+ 'activity/status': ActivityStatusEvent
44
+ }
45
+ }
package/src/index.ts CHANGED
@@ -1,228 +1,228 @@
1
- /**
2
- * working-activity — a live "working line" for DeepSeek Harness agents.
3
- *
4
- * Folds the durable session stream (turn/step/tool/stream events) plus
5
- * `agent/status` into a playful real-time status line, then publishes it two
6
- * ways, both optional:
7
- *
8
- * - TUI: registers the `${activity}` prompt slot on `ctx.tuiPrompt` when the
9
- * TUI is composed; add `${activity}` to `theme.leftPrompt` to see it.
10
- * - Session log: appends log-only `activity/status` events (never surface
11
- * events) for Web and other UI consumers; replay ignores them.
12
- *
13
- * The state machine itself lives in `./status.ts` (pure, clock-injected); this
14
- * module only wires events, the render tick, and the two sinks.
15
- * @module @deepseek-ai/dsh-working-activity
16
- */
17
-
18
- import type { Context } from '@deepseek-ai/cordis'
19
- import z from '@deepseek-ai/schemastery'
20
- import type { Session } from '@deepseek-ai/dsh-session'
21
- // Type-only: resolves the agent/status cordis event declaration.
22
- import type {} from '@deepseek-ai/dsh-agent'
23
- // Type-only: resolves ctx.systemPrompt for the narration section injection.
24
- import type {} from '@deepseek-ai/dsh-system-prompt'
25
- import { ActivityTracker } from './status.js'
26
- import { registerActivityEventType } from './registration.js'
27
- import type { ActivityState } from './status.js'
28
- import type { ActivityStatusEvent } from './events.js'
29
- // Re-export the event type + SessionEventMap merge: the package root must carry
30
- // the declare-module side effect for consumers resolving the built d.ts.
31
- export type * from './events.js'
32
-
33
- export const name = 'working-activity'
34
-
35
- /** Configurable knobs; every key has a sane default. */
36
- export type Config = {
37
- /** Playful copy pool; false renders plain functional labels. */
38
- phrases?: boolean
39
- /** Append `activity/status` session events for UI consumers. Default OFF:
40
- * dsh-session's append() cannot mark events ignorable, and the resume
41
- * read path refuses logs containing unknown non-ignorable types — every
42
- * appended snapshot makes the whole session unresumable. Re-enable only
43
- * for a log-replaying consumer on a harness that supports ignorable
44
- * appends. The live status line (prompt slot / session events) is
45
- * unaffected by this flag. */
46
- publish?: boolean
47
- /** Status render tick interval in ms. */
48
- tickMs?: number
49
- /** Minimum interval in ms between published events while the line is stable. */
50
- publishIntervalMs?: number
51
- /** Maximum displayed detail length (paths/commands/patterns). */
52
- detailLimit?: number
53
- /** Exact tool-name → action-copy pools (case-insensitive match). */
54
- customActions?: Record<string, string[]>
55
- /** Inject the `⏵` self-narration contract into the system prompt and surface it. */
56
- narrate?: boolean
57
- }
58
-
59
- // Explicit annotation: the inferred z.dict output references cosmokit's
60
- // Dict through a pnpm-virtual path, which is not portable in declaration
61
- // emit (TS2883) when the dependency graph shifts. The global `Schemastery`
62
- // interface comes from schemastery's own d.ts (declare global).
63
- export const Config: Schemastery<Config> = z.object({
64
- phrases: z.boolean().default(true),
65
- publish: z.boolean().default(false),
66
- tickMs: z.number().step(50).min(100).max(5000).default(500),
67
- publishIntervalMs: z.number().step(500).min(500).max(30_000).default(2000),
68
- detailLimit: z.number().step(1).min(8).max(120).default(40),
69
- customActions: z.dict(z.array(z.string())).default({}),
70
- narrate: z.boolean().default(true),
71
- })
72
-
73
- /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
74
- interface TuiPromptLike {
75
- register(name: string, initialValue?: string): {
76
- set(value: string | undefined): void
77
- dispose(): void
78
- }
79
- }
80
-
81
- /** Resolved plugin configuration after schema defaults. */
82
- interface ResolvedConfig {
83
- phrases: boolean
84
- publish: boolean
85
- tickMs: number
86
- publishIntervalMs: number
87
- detailLimit: number
88
- customActions: Record<string, string[]>
89
- narrate: boolean
90
- }
91
-
92
- /** The self-narration contract injected into the system prompt (narrate on). */
93
- const NARRATE_INSTRUCTION =
94
- '[状态栏] 你有一个状态栏展示给用户。【必须】在每个步骤/子任务开始时(不只是调用工具前),在回复正文的最前面单独写一行:⏵ 你在做的具体事情(不超过20字),然后换行继续正常回复。整轮回复只写一行 ⏵,不要重复。信息为主——让人一眼知道你在干什么,风格自然、可以带点俏皮。例:⏵ 修复登录页样式、⏵ 查一下报错原因、⏵ 给补丁跑个验证。切换任务时必须更新。'
95
-
96
- /**
97
- * Wire the working-activity plugin.
98
- * @param ctx - Cordis context (agent loop + session services composed).
99
- * @param config - Validated plugin config (schema defaults applied).
100
- */
101
- export function apply(ctx: Context, config: Config = {}): void {
102
- // Register the event type BEFORE anything can publish or validate: the
103
- // strict read paths (resume seed validation, persistence load) refuse
104
- // logs with unknown non-ignorable types. Registration is unconditional —
105
- // it also protects READING logs written by an earlier publish:true era
106
- // in processes where publishing itself is off. See registration.ts.
107
- registerActivityEventType()
108
- const resolved: ResolvedConfig = {
109
- phrases: config.phrases ?? true,
110
- publish: config.publish ?? false,
111
- tickMs: config.tickMs ?? 500,
112
- publishIntervalMs: config.publishIntervalMs ?? 2000,
113
- detailLimit: config.detailLimit ?? 40,
114
- narrate: config.narrate ?? true,
115
- customActions: config.customActions ?? {},
116
- }
117
- const trackers = new Map<Session, ActivityTracker>()
118
- let activeSession: Session | undefined
119
- let lastPublishedLine: string | undefined
120
- let lastPublishedPhase: string | undefined
121
- let lastPublishAt = 0
122
-
123
- // Optional TUI seam: no TUI composed -> no slot, no error. The register()
124
- // call is itself effect-owned, so fiber disposal unregisters the slot.
125
- const prompt = ctx.get('tuiPrompt', false) as TuiPromptLike | undefined
126
- const promptHandle = prompt?.register('activity', undefined)
127
-
128
- // The `⏵` self-narration contract rides the stable system-prompt sections:
129
- // injected when the systemPrompt service is composed (agent assemblies
130
- // always mount it), removed with this fiber.
131
- if (resolved.narrate) {
132
- ctx.inject(['systemPrompt'], (promptCtx) => {
133
- promptCtx.systemPrompt.section({
134
- name: 'working-activity:narrate',
135
- order: 60,
136
- text: NARRATE_INSTRUCTION,
137
- })
138
- })
139
- }
140
-
141
- const trackerFor = (session: Session): ActivityTracker => {
142
- let tracker = trackers.get(session)
143
- if (tracker === undefined) {
144
- tracker = new ActivityTracker(
145
- { phrases: resolved.phrases, detailLimit: resolved.detailLimit, showIdle: false },
146
- Date.now,
147
- resolved.customActions,
148
- )
149
- trackers.set(session, tracker)
150
- }
151
- return tracker
152
- }
153
-
154
- /**
155
- * Publish one rendered snapshot: TUI slot update + throttled session event.
156
- * Callers snapshot the tracker state at event time and hand it here, so a
157
- * burst of fast events (e.g. a synchronous tool call+result) cannot lose an
158
- * intermediate phase; the append itself runs inside a microtask because the
159
- * session's appending guard is still set while session/event callbacks run.
160
- */
161
- const publish = (session: Session, state: ActivityState): void => {
162
- queueMicrotask(() => {
163
- const line = state.phase === 'idle' ? undefined : state.line
164
- promptHandle?.set(line)
165
- if (!resolved.publish) return
166
- const nowMs = Date.now()
167
- const lineChanged = state.line !== lastPublishedLine
168
- const phaseChanged = state.phase !== lastPublishedPhase
169
- // Live phases republish on a throttle so elapsed times stay current;
170
- // settled phases (idle/done) publish only when the line itself changes.
171
- const liveThrottle = state.phase !== 'idle' && state.phase !== 'done'
172
- && nowMs - lastPublishAt >= resolved.publishIntervalMs
173
- if (!lineChanged && !phaseChanged && !liveThrottle) return
174
- // Optional fields must be omitted (not undefined): session append rejects
175
- // data JSON would discard, and `activity/status` is a lossless-JSON event.
176
- const payload: ActivityStatusEvent = {
177
- phase: state.phase,
178
- line: state.line,
179
- toolCount: state.toolCount,
180
- turnElapsedMs: state.turnElapsedMs,
181
- phaseStartedAt: state.phaseStartedAt,
182
- ...(state.label === undefined ? {} : { label: state.label }),
183
- ...(state.detail === undefined ? {} : { detail: state.detail }),
184
- ...(state.phrase === undefined ? {} : { phrase: state.phrase }),
185
- }
186
- try {
187
- session.append('activity/status', payload)
188
- lastPublishedLine = state.line
189
- lastPublishedPhase = state.phase
190
- lastPublishAt = nowMs
191
- } catch {
192
- // Session closed or the append guard still held: drop this snapshot;
193
- // the next tick retries the same line.
194
- }
195
- })
196
- }
197
-
198
- ctx.on('session/event', (session, event) => {
199
- const tracker = trackerFor(session)
200
- tracker.onSessionEvent(event)
201
- activeSession = session
202
- publish(session, tracker.render())
203
- })
204
-
205
- ctx.on('session/disposed', (session) => {
206
- trackers.delete(session)
207
- if (activeSession === session) activeSession = undefined
208
- })
209
-
210
- ctx.on('agent/status', ({ agent, status }) => {
211
- const session = agent.session
212
- const tracker = trackerFor(session)
213
- tracker.onAgentStatus(status)
214
- activeSession = session
215
- publish(session, tracker.render())
216
- })
217
-
218
- // Continuous tick: elapsed times and the phrase rotation move on their own.
219
- // A manual timer keeps this plugin free of the @cordisjs/plugin-timer mixin;
220
- // the effect disposer clears it when this fiber unloads.
221
- const tickTimer = setInterval(() => {
222
- if (activeSession === undefined) return
223
- const tracker = trackers.get(activeSession)
224
- if (tracker === undefined) return
225
- publish(activeSession, tracker.render())
226
- }, resolved.tickMs)
227
- ctx.effect(() => () => { clearInterval(tickTimer) }, 'working-activity tick timer')
228
- }
1
+ /**
2
+ * working-activity — a live "working line" for DeepSeek Harness agents.
3
+ *
4
+ * Folds the durable session stream (turn/step/tool/stream events) plus
5
+ * `agent/status` into a playful real-time status line, then publishes it two
6
+ * ways, both optional:
7
+ *
8
+ * - TUI: registers the `${activity}` prompt slot on `ctx.tuiPrompt` when the
9
+ * TUI is composed; add `${activity}` to `theme.leftPrompt` to see it.
10
+ * - Session log: appends log-only `activity/status` events (never surface
11
+ * events) for Web and other UI consumers; replay ignores them.
12
+ *
13
+ * The state machine itself lives in `./status.ts` (pure, clock-injected); this
14
+ * module only wires events, the render tick, and the two sinks.
15
+ * @module @deepseek-ai/dsh-working-activity
16
+ */
17
+
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import z from '@deepseek-ai/schemastery'
20
+ import type { Session } from '@deepseek-ai/dsh-session'
21
+ // Type-only: resolves the agent/status cordis event declaration.
22
+ import type {} from '@deepseek-ai/dsh-agent'
23
+ // Type-only: resolves ctx.systemPrompt for the narration section injection.
24
+ import type {} from '@deepseek-ai/dsh-system-prompt'
25
+ import { ActivityTracker } from './status.js'
26
+ import { registerActivityEventType } from './registration.js'
27
+ import type { ActivityState } from './status.js'
28
+ import type { ActivityStatusEvent } from './events.js'
29
+ // Re-export the event type + SessionEventMap merge: the package root must carry
30
+ // the declare-module side effect for consumers resolving the built d.ts.
31
+ export type * from './events.js'
32
+
33
+ export const name = 'working-activity'
34
+
35
+ /** Configurable knobs; every key has a sane default. */
36
+ export type Config = {
37
+ /** Playful copy pool; false renders plain functional labels. */
38
+ phrases?: boolean
39
+ /** Append `activity/status` session events for UI consumers. Default OFF:
40
+ * dsh-session's append() cannot mark events ignorable, and the resume
41
+ * read path refuses logs containing unknown non-ignorable types — every
42
+ * appended snapshot makes the whole session unresumable. Re-enable only
43
+ * for a log-replaying consumer on a harness that supports ignorable
44
+ * appends. The live status line (prompt slot / session events) is
45
+ * unaffected by this flag. */
46
+ publish?: boolean
47
+ /** Status render tick interval in ms. */
48
+ tickMs?: number
49
+ /** Minimum interval in ms between published events while the line is stable. */
50
+ publishIntervalMs?: number
51
+ /** Maximum displayed detail length (paths/commands/patterns). */
52
+ detailLimit?: number
53
+ /** Exact tool-name → action-copy pools (case-insensitive match). */
54
+ customActions?: Record<string, string[]>
55
+ /** Inject the `⏵` self-narration contract into the system prompt and surface it. */
56
+ narrate?: boolean
57
+ }
58
+
59
+ // Explicit annotation: the inferred z.dict output references cosmokit's
60
+ // Dict through a pnpm-virtual path, which is not portable in declaration
61
+ // emit (TS2883) when the dependency graph shifts. The global `Schemastery`
62
+ // interface comes from schemastery's own d.ts (declare global).
63
+ export const Config: Schemastery<Config> = z.object({
64
+ phrases: z.boolean().default(true),
65
+ publish: z.boolean().default(false),
66
+ tickMs: z.number().step(50).min(100).max(5000).default(500),
67
+ publishIntervalMs: z.number().step(500).min(500).max(30_000).default(2000),
68
+ detailLimit: z.number().step(1).min(8).max(120).default(40),
69
+ customActions: z.dict(z.array(z.string())).default({}),
70
+ narrate: z.boolean().default(true),
71
+ })
72
+
73
+ /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
74
+ interface TuiPromptLike {
75
+ register(name: string, initialValue?: string): {
76
+ set(value: string | undefined): void
77
+ dispose(): void
78
+ }
79
+ }
80
+
81
+ /** Resolved plugin configuration after schema defaults. */
82
+ interface ResolvedConfig {
83
+ phrases: boolean
84
+ publish: boolean
85
+ tickMs: number
86
+ publishIntervalMs: number
87
+ detailLimit: number
88
+ customActions: Record<string, string[]>
89
+ narrate: boolean
90
+ }
91
+
92
+ /** The self-narration contract injected into the system prompt (narrate on). */
93
+ const NARRATE_INSTRUCTION =
94
+ '[状态栏] 你有一个状态栏展示给用户。【必须】在每个步骤/子任务开始时(不只是调用工具前),在回复正文的最前面单独写一行:⏵ 你在做的具体事情(不超过20字),然后换行继续正常回复。整轮回复只写一行 ⏵,不要重复。信息为主——让人一眼知道你在干什么,风格自然、可以带点俏皮。例:⏵ 修复登录页样式、⏵ 查一下报错原因、⏵ 给补丁跑个验证。切换任务时必须更新。'
95
+
96
+ /**
97
+ * Wire the working-activity plugin.
98
+ * @param ctx - Cordis context (agent loop + session services composed).
99
+ * @param config - Validated plugin config (schema defaults applied).
100
+ */
101
+ export function apply(ctx: Context, config: Config = {}): void {
102
+ // Register the event type BEFORE anything can publish or validate: the
103
+ // strict read paths (resume seed validation, persistence load) refuse
104
+ // logs with unknown non-ignorable types. Registration is unconditional —
105
+ // it also protects READING logs written by an earlier publish:true era
106
+ // in processes where publishing itself is off. See registration.ts.
107
+ registerActivityEventType()
108
+ const resolved: ResolvedConfig = {
109
+ phrases: config.phrases ?? true,
110
+ publish: config.publish ?? false,
111
+ tickMs: config.tickMs ?? 500,
112
+ publishIntervalMs: config.publishIntervalMs ?? 2000,
113
+ detailLimit: config.detailLimit ?? 40,
114
+ narrate: config.narrate ?? true,
115
+ customActions: config.customActions ?? {},
116
+ }
117
+ const trackers = new Map<Session, ActivityTracker>()
118
+ let activeSession: Session | undefined
119
+ let lastPublishedLine: string | undefined
120
+ let lastPublishedPhase: string | undefined
121
+ let lastPublishAt = 0
122
+
123
+ // Optional TUI seam: no TUI composed -> no slot, no error. The register()
124
+ // call is itself effect-owned, so fiber disposal unregisters the slot.
125
+ const prompt = ctx.get('tuiPrompt', false) as TuiPromptLike | undefined
126
+ const promptHandle = prompt?.register('activity', undefined)
127
+
128
+ // The `⏵` self-narration contract rides the stable system-prompt sections:
129
+ // injected when the systemPrompt service is composed (agent assemblies
130
+ // always mount it), removed with this fiber.
131
+ if (resolved.narrate) {
132
+ ctx.inject(['systemPrompt'], (promptCtx) => {
133
+ promptCtx.systemPrompt.section({
134
+ name: 'working-activity:narrate',
135
+ order: 60,
136
+ text: NARRATE_INSTRUCTION,
137
+ })
138
+ })
139
+ }
140
+
141
+ const trackerFor = (session: Session): ActivityTracker => {
142
+ let tracker = trackers.get(session)
143
+ if (tracker === undefined) {
144
+ tracker = new ActivityTracker(
145
+ { phrases: resolved.phrases, detailLimit: resolved.detailLimit, showIdle: false },
146
+ Date.now,
147
+ resolved.customActions,
148
+ )
149
+ trackers.set(session, tracker)
150
+ }
151
+ return tracker
152
+ }
153
+
154
+ /**
155
+ * Publish one rendered snapshot: TUI slot update + throttled session event.
156
+ * Callers snapshot the tracker state at event time and hand it here, so a
157
+ * burst of fast events (e.g. a synchronous tool call+result) cannot lose an
158
+ * intermediate phase; the append itself runs inside a microtask because the
159
+ * session's appending guard is still set while session/event callbacks run.
160
+ */
161
+ const publish = (session: Session, state: ActivityState): void => {
162
+ queueMicrotask(() => {
163
+ const line = state.phase === 'idle' ? undefined : state.line
164
+ promptHandle?.set(line)
165
+ if (!resolved.publish) return
166
+ const nowMs = Date.now()
167
+ const lineChanged = state.line !== lastPublishedLine
168
+ const phaseChanged = state.phase !== lastPublishedPhase
169
+ // Live phases republish on a throttle so elapsed times stay current;
170
+ // settled phases (idle/done) publish only when the line itself changes.
171
+ const liveThrottle = state.phase !== 'idle' && state.phase !== 'done'
172
+ && nowMs - lastPublishAt >= resolved.publishIntervalMs
173
+ if (!lineChanged && !phaseChanged && !liveThrottle) return
174
+ // Optional fields must be omitted (not undefined): session append rejects
175
+ // data JSON would discard, and `activity/status` is a lossless-JSON event.
176
+ const payload: ActivityStatusEvent = {
177
+ phase: state.phase,
178
+ line: state.line,
179
+ toolCount: state.toolCount,
180
+ turnElapsedMs: state.turnElapsedMs,
181
+ phaseStartedAt: state.phaseStartedAt,
182
+ ...(state.label === undefined ? {} : { label: state.label }),
183
+ ...(state.detail === undefined ? {} : { detail: state.detail }),
184
+ ...(state.phrase === undefined ? {} : { phrase: state.phrase }),
185
+ }
186
+ try {
187
+ session.append('activity/status', payload)
188
+ lastPublishedLine = state.line
189
+ lastPublishedPhase = state.phase
190
+ lastPublishAt = nowMs
191
+ } catch {
192
+ // Session closed or the append guard still held: drop this snapshot;
193
+ // the next tick retries the same line.
194
+ }
195
+ })
196
+ }
197
+
198
+ ctx.on('session/event', (session, event) => {
199
+ const tracker = trackerFor(session)
200
+ tracker.onSessionEvent(event)
201
+ activeSession = session
202
+ publish(session, tracker.render())
203
+ })
204
+
205
+ ctx.on('session/disposed', (session) => {
206
+ trackers.delete(session)
207
+ if (activeSession === session) activeSession = undefined
208
+ })
209
+
210
+ ctx.on('agent/status', ({ agent, status }) => {
211
+ const session = agent.session
212
+ const tracker = trackerFor(session)
213
+ tracker.onAgentStatus(status)
214
+ activeSession = session
215
+ publish(session, tracker.render())
216
+ })
217
+
218
+ // Continuous tick: elapsed times and the phrase rotation move on their own.
219
+ // A manual timer keeps this plugin free of the @cordisjs/plugin-timer mixin;
220
+ // the effect disposer clears it when this fiber unloads.
221
+ const tickTimer = setInterval(() => {
222
+ if (activeSession === undefined) return
223
+ const tracker = trackers.get(activeSession)
224
+ if (tracker === undefined) return
225
+ publish(activeSession, tracker.render())
226
+ }, resolved.tickMs)
227
+ ctx.effect(() => () => { clearInterval(tickTimer) }, 'working-activity tick timer')
228
+ }