dsh-working-activity 0.2.3 → 0.2.5

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,217 +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 type { ActivityState } from './status.js'
27
- import type { ActivityStatusEvent } from './events.js'
28
- // Re-export the event type + SessionEventMap merge: the package root must carry
29
- // the declare-module side effect for consumers resolving the built d.ts.
30
- export type * from './events.js'
31
-
32
- export const name = 'working-activity'
33
-
34
- /** Configurable knobs; every key has a sane default. */
35
- export type Config = {
36
- /** Playful copy pool; false renders plain functional labels. */
37
- phrases?: boolean
38
- /** Append `activity/status` session events for UI consumers. Default OFF:
39
- * dsh-session's append() cannot mark events ignorable, and the resume
40
- * read path refuses logs containing unknown non-ignorable types every
41
- * appended snapshot makes the whole session unresumable. Re-enable only
42
- * for a log-replaying consumer on a harness that supports ignorable
43
- * appends. The live status line (prompt slot / session events) is
44
- * unaffected by this flag. */
45
- publish?: boolean
46
- /** Status render tick interval in ms. */
47
- tickMs?: number
48
- /** Minimum interval in ms between published events while the line is stable. */
49
- publishIntervalMs?: number
50
- /** Maximum displayed detail length (paths/commands/patterns). */
51
- detailLimit?: number
52
- /** Exact tool-name → action-copy pools (case-insensitive match). */
53
- customActions?: Record<string, string[]>
54
- /** Inject the `⏵` self-narration contract into the system prompt and surface it. */
55
- narrate?: boolean
56
- }
57
-
58
- export const Config = z.object({
59
- phrases: z.boolean().default(true),
60
- publish: z.boolean().default(false),
61
- tickMs: z.number().step(50).min(100).max(5000).default(500),
62
- publishIntervalMs: z.number().step(500).min(500).max(30_000).default(2000),
63
- detailLimit: z.number().step(1).min(8).max(120).default(40),
64
- customActions: z.dict(z.array(z.string())).default({}),
65
- narrate: z.boolean().default(true),
66
- })
67
-
68
- /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
69
- interface TuiPromptLike {
70
- register(name: string, initialValue?: string): {
71
- set(value: string | undefined): void
72
- dispose(): void
73
- }
74
- }
75
-
76
- /** Resolved plugin configuration after schema defaults. */
77
- interface ResolvedConfig {
78
- phrases: boolean
79
- publish: boolean
80
- tickMs: number
81
- publishIntervalMs: number
82
- detailLimit: number
83
- customActions: Record<string, string[]>
84
- narrate: boolean
85
- }
86
-
87
- /** The self-narration contract injected into the system prompt (narrate on). */
88
- const NARRATE_INSTRUCTION =
89
- '[状态栏] 你有一个状态栏展示给用户。【必须】在每个步骤/子任务开始时(不只是调用工具前),在回复正文的最前面单独写一行:⏵ 你在做的具体事情(不超过20字),然后换行继续正常回复。整轮回复只写一行 ⏵,不要重复。信息为主——让人一眼知道你在干什么,风格自然、可以带点俏皮。例:⏵ 修复登录页样式、⏵ 查一下报错原因、⏵ 给补丁跑个验证。切换任务时必须更新。'
90
-
91
- /**
92
- * Wire the working-activity plugin.
93
- * @param ctx - Cordis context (agent loop + session services composed).
94
- * @param config - Validated plugin config (schema defaults applied).
95
- */
96
- export function apply(ctx: Context, config: Config = {}): void {
97
- const resolved: ResolvedConfig = {
98
- phrases: config.phrases ?? true,
99
- publish: config.publish ?? false,
100
- tickMs: config.tickMs ?? 500,
101
- publishIntervalMs: config.publishIntervalMs ?? 2000,
102
- detailLimit: config.detailLimit ?? 40,
103
- narrate: config.narrate ?? true,
104
- customActions: config.customActions ?? {},
105
- }
106
- const trackers = new Map<Session, ActivityTracker>()
107
- let activeSession: Session | undefined
108
- let lastPublishedLine: string | undefined
109
- let lastPublishedPhase: string | undefined
110
- let lastPublishAt = 0
111
-
112
- // Optional TUI seam: no TUI composed -> no slot, no error. The register()
113
- // call is itself effect-owned, so fiber disposal unregisters the slot.
114
- const prompt = ctx.get('tuiPrompt', false) as TuiPromptLike | undefined
115
- const promptHandle = prompt?.register('activity', undefined)
116
-
117
- // The `⏵` self-narration contract rides the stable system-prompt sections:
118
- // injected when the systemPrompt service is composed (agent assemblies
119
- // always mount it), removed with this fiber.
120
- if (resolved.narrate) {
121
- ctx.inject(['systemPrompt'], (promptCtx) => {
122
- promptCtx.systemPrompt.section({
123
- name: 'working-activity:narrate',
124
- order: 60,
125
- text: NARRATE_INSTRUCTION,
126
- })
127
- })
128
- }
129
-
130
- const trackerFor = (session: Session): ActivityTracker => {
131
- let tracker = trackers.get(session)
132
- if (tracker === undefined) {
133
- tracker = new ActivityTracker(
134
- { phrases: resolved.phrases, detailLimit: resolved.detailLimit, showIdle: false },
135
- Date.now,
136
- resolved.customActions,
137
- )
138
- trackers.set(session, tracker)
139
- }
140
- return tracker
141
- }
142
-
143
- /**
144
- * Publish one rendered snapshot: TUI slot update + throttled session event.
145
- * Callers snapshot the tracker state at event time and hand it here, so a
146
- * burst of fast events (e.g. a synchronous tool call+result) cannot lose an
147
- * intermediate phase; the append itself runs inside a microtask because the
148
- * session's appending guard is still set while session/event callbacks run.
149
- */
150
- const publish = (session: Session, state: ActivityState): void => {
151
- queueMicrotask(() => {
152
- const line = state.phase === 'idle' ? undefined : state.line
153
- promptHandle?.set(line)
154
- if (!resolved.publish) return
155
- const nowMs = Date.now()
156
- const lineChanged = state.line !== lastPublishedLine
157
- const phaseChanged = state.phase !== lastPublishedPhase
158
- // Live phases republish on a throttle so elapsed times stay current;
159
- // settled phases (idle/done) publish only when the line itself changes.
160
- const liveThrottle = state.phase !== 'idle' && state.phase !== 'done'
161
- && nowMs - lastPublishAt >= resolved.publishIntervalMs
162
- if (!lineChanged && !phaseChanged && !liveThrottle) return
163
- // Optional fields must be omitted (not undefined): session append rejects
164
- // data JSON would discard, and `activity/status` is a lossless-JSON event.
165
- const payload: ActivityStatusEvent = {
166
- phase: state.phase,
167
- line: state.line,
168
- toolCount: state.toolCount,
169
- turnElapsedMs: state.turnElapsedMs,
170
- phaseStartedAt: state.phaseStartedAt,
171
- ...(state.label === undefined ? {} : { label: state.label }),
172
- ...(state.detail === undefined ? {} : { detail: state.detail }),
173
- ...(state.phrase === undefined ? {} : { phrase: state.phrase }),
174
- }
175
- try {
176
- session.append('activity/status', payload)
177
- lastPublishedLine = state.line
178
- lastPublishedPhase = state.phase
179
- lastPublishAt = nowMs
180
- } catch {
181
- // Session closed or the append guard still held: drop this snapshot;
182
- // the next tick retries the same line.
183
- }
184
- })
185
- }
186
-
187
- ctx.on('session/event', (session, event) => {
188
- const tracker = trackerFor(session)
189
- tracker.onSessionEvent(event)
190
- activeSession = session
191
- publish(session, tracker.render())
192
- })
193
-
194
- ctx.on('session/disposed', (session) => {
195
- trackers.delete(session)
196
- if (activeSession === session) activeSession = undefined
197
- })
198
-
199
- ctx.on('agent/status', ({ agent, status }) => {
200
- const session = agent.session
201
- const tracker = trackerFor(session)
202
- tracker.onAgentStatus(status)
203
- activeSession = session
204
- publish(session, tracker.render())
205
- })
206
-
207
- // Continuous tick: elapsed times and the phrase rotation move on their own.
208
- // A manual timer keeps this plugin free of the @cordisjs/plugin-timer mixin;
209
- // the effect disposer clears it when this fiber unloads.
210
- const tickTimer = setInterval(() => {
211
- if (activeSession === undefined) return
212
- const tracker = trackers.get(activeSession)
213
- if (tracker === undefined) return
214
- publish(activeSession, tracker.render())
215
- }, resolved.tickMs)
216
- ctx.effect(() => () => { clearInterval(tickTimer) }, 'working-activity tick timer')
217
- }
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
+ }
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))