dsh-working-activity 0.2.6 → 0.3.0

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.
package/src/index.ts CHANGED
@@ -1,228 +1,237 @@
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 { setLangOverride, t } from './lang.js'
28
+ import type { ActivityState } from './status.js'
29
+ import type { ActivityStatusEvent } from './events.js'
30
+ // Re-export the event type + SessionEventMap merge: the package root must carry
31
+ // the declare-module side effect for consumers resolving the built d.ts.
32
+ export type * from './events.js'
33
+
34
+ export const name = 'working-activity'
35
+
36
+ /** Configurable knobs; every key has a sane default. */
37
+ export type Config = {
38
+ /** Playful copy pool; false renders plain functional labels. */
39
+ phrases?: boolean
40
+ /** Append `activity/status` session events for UI consumers. Default OFF:
41
+ * dsh-session's append() cannot mark events ignorable, and the resume
42
+ * read path refuses logs containing unknown non-ignorable types — every
43
+ * appended snapshot makes the whole session unresumable. Re-enable only
44
+ * for a log-replaying consumer on a harness that supports ignorable
45
+ * appends. The live status line (prompt slot / session events) is
46
+ * unaffected by this flag. */
47
+ publish?: boolean
48
+ /** Status render tick interval in ms. */
49
+ tickMs?: number
50
+ /** Minimum interval in ms between published events while the line is stable. */
51
+ publishIntervalMs?: number
52
+ /** Maximum displayed detail length (paths/commands/patterns). */
53
+ detailLimit?: number
54
+ /** Exact tool-name → action-copy pools (case-insensitive match). */
55
+ customActions?: Record<string, string[]>
56
+ /** Inject the `⏵` self-narration contract into the system prompt and surface it. */
57
+ narrate?: boolean
58
+ /** UI language: `auto` follows `DSH_TUI_LANG` → `~/.dsh-tui/lang.json` →
59
+ * OS locale zh; `zh`/`en` pin the copy directly. */
60
+ lang?: 'auto' | 'zh' | 'en'
61
+ }
62
+
63
+ // Explicit annotation: the inferred z.dict output references cosmokit's
64
+ // Dict through a pnpm-virtual path, which is not portable in declaration
65
+ // emit (TS2883) when the dependency graph shifts. The global `Schemastery`
66
+ // interface comes from schemastery's own d.ts (declare global).
67
+ export const Config: Schemastery<Config> = z.object({
68
+ phrases: z.boolean().default(true),
69
+ publish: z.boolean().default(false),
70
+ tickMs: z.number().step(50).min(100).max(5000).default(500),
71
+ publishIntervalMs: z.number().step(500).min(500).max(30_000).default(2000),
72
+ detailLimit: z.number().step(1).min(8).max(120).default(40),
73
+ customActions: z.dict(z.array(z.string())).default({}),
74
+ narrate: z.boolean().default(true),
75
+ lang: z.union(['auto', 'zh', 'en']).default('auto'),
76
+ })
77
+
78
+ /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
79
+ interface TuiPromptLike {
80
+ register(name: string, initialValue?: string): {
81
+ set(value: string | undefined): void
82
+ dispose(): void
83
+ }
84
+ }
85
+
86
+ /** Resolved plugin configuration after schema defaults. */
87
+ interface ResolvedConfig {
88
+ phrases: boolean
89
+ publish: boolean
90
+ tickMs: number
91
+ publishIntervalMs: number
92
+ detailLimit: number
93
+ customActions: Record<string, string[]>
94
+ narrate: boolean
95
+ lang: 'auto' | 'zh' | 'en'
96
+ }
97
+
98
+ /**
99
+ * Wire the working-activity plugin.
100
+ * @param ctx - Cordis context (agent loop + session services composed).
101
+ * @param config - Validated plugin config (schema defaults applied).
102
+ */
103
+ export function apply(ctx: Context, config: Config = {}): void {
104
+ // Register the event type BEFORE anything can publish or validate: the
105
+ // strict read paths (resume seed validation, persistence load) refuse
106
+ // logs with unknown non-ignorable types. Registration is unconditional
107
+ // it also protects READING logs written by an earlier publish:true era
108
+ // in processes where publishing itself is off. See registration.ts.
109
+ registerActivityEventType()
110
+ const resolved: ResolvedConfig = {
111
+ phrases: config.phrases ?? true,
112
+ publish: config.publish ?? false,
113
+ tickMs: config.tickMs ?? 500,
114
+ publishIntervalMs: config.publishIntervalMs ?? 2000,
115
+ detailLimit: config.detailLimit ?? 40,
116
+ narrate: config.narrate ?? true,
117
+ lang: config.lang ?? 'auto',
118
+ customActions: config.customActions ?? {},
119
+ }
120
+ // A pinned plugin-level language beats the env/file chain; releasing it on
121
+ // dispose restores `auto` for any other composition in the process.
122
+ setLangOverride(resolved.lang)
123
+ ctx.effect(() => () => setLangOverride('auto'), 'working-activity lang override')
124
+ const trackers = new Map<Session, ActivityTracker>()
125
+ let activeSession: Session | undefined
126
+ let lastPublishedLine: string | undefined
127
+ let lastPublishedPhase: string | undefined
128
+ let lastPublishAt = 0
129
+
130
+ // Optional TUI seam: no TUI composed -> no slot, no error. The register()
131
+ // call is itself effect-owned, so fiber disposal unregisters the slot.
132
+ const prompt = ctx.get('tuiPrompt', false) as TuiPromptLike | undefined
133
+ const promptHandle = prompt?.register('activity', undefined)
134
+
135
+ // The `⏵` self-narration contract rides the stable system-prompt sections:
136
+ // injected when the systemPrompt service is composed (agent assemblies
137
+ // always mount it), removed with this fiber. The text is resolved at every
138
+ // assembly in the live language, so a `/lang` switch applies to the next
139
+ // turn without rebuilding the agent.
140
+ if (resolved.narrate) {
141
+ ctx.inject(['systemPrompt'], (promptCtx) => {
142
+ promptCtx.systemPrompt.section({
143
+ name: 'working-activity:narrate',
144
+ order: 60,
145
+ text: () => t('narrate-instruction'),
146
+ })
147
+ })
148
+ }
149
+
150
+ const trackerFor = (session: Session): ActivityTracker => {
151
+ let tracker = trackers.get(session)
152
+ if (tracker === undefined) {
153
+ tracker = new ActivityTracker(
154
+ { phrases: resolved.phrases, detailLimit: resolved.detailLimit, showIdle: false },
155
+ Date.now,
156
+ resolved.customActions,
157
+ )
158
+ trackers.set(session, tracker)
159
+ }
160
+ return tracker
161
+ }
162
+
163
+ /**
164
+ * Publish one rendered snapshot: TUI slot update + throttled session event.
165
+ * Callers snapshot the tracker state at event time and hand it here, so a
166
+ * burst of fast events (e.g. a synchronous tool call+result) cannot lose an
167
+ * intermediate phase; the append itself runs inside a microtask because the
168
+ * session's appending guard is still set while session/event callbacks run.
169
+ */
170
+ const publish = (session: Session, state: ActivityState): void => {
171
+ queueMicrotask(() => {
172
+ const line = state.phase === 'idle' ? undefined : state.line
173
+ promptHandle?.set(line)
174
+ if (!resolved.publish) return
175
+ const nowMs = Date.now()
176
+ const lineChanged = state.line !== lastPublishedLine
177
+ const phaseChanged = state.phase !== lastPublishedPhase
178
+ // Live phases republish on a throttle so elapsed times stay current;
179
+ // settled phases (idle/done) publish only when the line itself changes.
180
+ const liveThrottle = state.phase !== 'idle' && state.phase !== 'done'
181
+ && nowMs - lastPublishAt >= resolved.publishIntervalMs
182
+ if (!lineChanged && !phaseChanged && !liveThrottle) return
183
+ // Optional fields must be omitted (not undefined): session append rejects
184
+ // data JSON would discard, and `activity/status` is a lossless-JSON event.
185
+ const payload: ActivityStatusEvent = {
186
+ phase: state.phase,
187
+ line: state.line,
188
+ toolCount: state.toolCount,
189
+ turnElapsedMs: state.turnElapsedMs,
190
+ phaseStartedAt: state.phaseStartedAt,
191
+ ...(state.label === undefined ? {} : { label: state.label }),
192
+ ...(state.detail === undefined ? {} : { detail: state.detail }),
193
+ ...(state.phrase === undefined ? {} : { phrase: state.phrase }),
194
+ }
195
+ try {
196
+ session.append('activity/status', payload)
197
+ lastPublishedLine = state.line
198
+ lastPublishedPhase = state.phase
199
+ lastPublishAt = nowMs
200
+ } catch {
201
+ // Session closed or the append guard still held: drop this snapshot;
202
+ // the next tick retries the same line.
203
+ }
204
+ })
205
+ }
206
+
207
+ ctx.on('session/event', (session, event) => {
208
+ const tracker = trackerFor(session)
209
+ tracker.onSessionEvent(event)
210
+ activeSession = session
211
+ publish(session, tracker.render())
212
+ })
213
+
214
+ ctx.on('session/disposed', (session) => {
215
+ trackers.delete(session)
216
+ if (activeSession === session) activeSession = undefined
217
+ })
218
+
219
+ ctx.on('agent/status', ({ agent, status }) => {
220
+ const session = agent.session
221
+ const tracker = trackerFor(session)
222
+ tracker.onAgentStatus(status)
223
+ activeSession = session
224
+ publish(session, tracker.render())
225
+ })
226
+
227
+ // Continuous tick: elapsed times and the phrase rotation move on their own.
228
+ // A manual timer keeps this plugin free of the @cordisjs/plugin-timer mixin;
229
+ // the effect disposer clears it when this fiber unloads.
230
+ const tickTimer = setInterval(() => {
231
+ if (activeSession === undefined) return
232
+ const tracker = trackers.get(activeSession)
233
+ if (tracker === undefined) return
234
+ publish(activeSession, tracker.render())
235
+ }, resolved.tickMs)
236
+ ctx.effect(() => () => { clearInterval(tickTimer) }, 'working-activity tick timer')
237
+ }
package/src/invariant.ts CHANGED
@@ -1,69 +1,69 @@
1
- /**
2
- * Package-owned `activity/status` snapshot invariants.
3
- * @module dsh-working-activity/invariant
4
- */
5
-
6
- import type { Context } from '@deepseek-ai/cordis'
7
- import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
8
- import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
9
-
10
- /** Cordis companion plugin name. */
11
- export const name = 'working-activity-invariant'
12
- /** Service required before the companion can reserve package ownership. */
13
- export const inject = ['invariants']
14
-
15
- const PACKAGE_NAME = 'dsh-working-activity'
16
- const PHASES = new Set(['idle', 'waiting', 'thinking', 'tool', 'done'])
17
-
18
- /** Validate one published activity snapshot before it reaches the durable log. */
19
- function validateStatus(data: unknown, fail: InvariantFailure): void {
20
- const record = data as Record<string, unknown> | null
21
- if (record === null || typeof record !== 'object' || Array.isArray(record)) {
22
- fail('activity/status data must be an object')
23
- return
24
- }
25
- if (typeof record.phase !== 'string' || !PHASES.has(record.phase)) {
26
- fail(`activity/status carries unknown phase ${JSON.stringify(record.phase)}`)
27
- }
28
- if (typeof record.line !== 'string' || record.line.length === 0) {
29
- fail('activity/status line must be a non-empty string')
30
- }
31
- for (const key of ['toolCount', 'turnElapsedMs', 'phaseStartedAt']) {
32
- const value = record[key]
33
- if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
34
- fail(`activity/status ${key} must be a non-negative finite number`)
35
- }
36
- }
37
- for (const key of ['label', 'detail', 'phrase']) {
38
- if (record[key] !== undefined && typeof record[key] !== 'string') {
39
- fail(`activity/status ${key} must be a string when present`)
40
- }
41
- }
42
- }
43
-
44
- /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
45
- /** Validate the package-owned event shape and ignore unrelated events. */
46
- function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
47
- if (event.type === 'activity/status') validateStatus(event.data, fail)
48
- }
49
-
50
- /** Install validation for loaded and newly appended activity snapshots. */
51
- const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
52
- for (const session of ctx.sessions.list()) {
53
- for (const event of session.events) validateEvent(event, fail)
54
- }
55
- ctx.on('internal/dispatch', (_mode, eventName, args) => {
56
- if (eventName !== 'session/event') return
57
- const event = (args as [Session, SessionEvent])[1]
58
- validateEvent(event, fail)
59
- }, { global: true })
60
- }, { inject: ['sessions'] })
61
- /* jscpd:ignore-end */
62
-
63
- /**
64
- * Register the working-activity invariant companion.
65
- * @param ctx - Cordis context carrying the invariant service.
66
- * @returns the installed registration's disposer after setup succeeds.
67
- */
68
- export const apply = (ctx: Context): Promise<() => void> =>
69
- Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
1
+ /**
2
+ * Package-owned `activity/status` snapshot invariants.
3
+ * @module dsh-working-activity/invariant
4
+ */
5
+
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
8
+ import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
9
+
10
+ /** Cordis companion plugin name. */
11
+ export const name = 'working-activity-invariant'
12
+ /** Service required before the companion can reserve package ownership. */
13
+ export const inject = ['invariants']
14
+
15
+ const PACKAGE_NAME = 'dsh-working-activity'
16
+ const PHASES = new Set(['idle', 'waiting', 'thinking', 'tool', 'done'])
17
+
18
+ /** Validate one published activity snapshot before it reaches the durable log. */
19
+ function validateStatus(data: unknown, fail: InvariantFailure): void {
20
+ const record = data as Record<string, unknown> | null
21
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
22
+ fail('activity/status data must be an object')
23
+ return
24
+ }
25
+ if (typeof record.phase !== 'string' || !PHASES.has(record.phase)) {
26
+ fail(`activity/status carries unknown phase ${JSON.stringify(record.phase)}`)
27
+ }
28
+ if (typeof record.line !== 'string' || record.line.length === 0) {
29
+ fail('activity/status line must be a non-empty string')
30
+ }
31
+ for (const key of ['toolCount', 'turnElapsedMs', 'phaseStartedAt']) {
32
+ const value = record[key]
33
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
34
+ fail(`activity/status ${key} must be a non-negative finite number`)
35
+ }
36
+ }
37
+ for (const key of ['label', 'detail', 'phrase']) {
38
+ if (record[key] !== undefined && typeof record[key] !== 'string') {
39
+ fail(`activity/status ${key} must be a string when present`)
40
+ }
41
+ }
42
+ }
43
+
44
+ /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
45
+ /** Validate the package-owned event shape and ignore unrelated events. */
46
+ function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
47
+ if (event.type === 'activity/status') validateStatus(event.data, fail)
48
+ }
49
+
50
+ /** Install validation for loaded and newly appended activity snapshots. */
51
+ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
52
+ for (const session of ctx.sessions.list()) {
53
+ for (const event of session.events) validateEvent(event, fail)
54
+ }
55
+ ctx.on('internal/dispatch', (_mode, eventName, args) => {
56
+ if (eventName !== 'session/event') return
57
+ const event = (args as [Session, SessionEvent])[1]
58
+ validateEvent(event, fail)
59
+ }, { global: true })
60
+ }, { inject: ['sessions'] })
61
+ /* jscpd:ignore-end */
62
+
63
+ /**
64
+ * Register the working-activity invariant companion.
65
+ * @param ctx - Cordis context carrying the invariant service.
66
+ * @returns the installed registration's disposer after setup succeeds.
67
+ */
68
+ export const apply = (ctx: Context): Promise<() => void> =>
69
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))