@kesike/reminder-dsh 0.0.0-stage → 0.2.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.
@@ -0,0 +1,77 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ const script = fileURLToPath(new URL('./flash.ps1', import.meta.url))
5
+
6
+ /** Coalesce taskbar attention for the same app; stop gracefully to clear native flashing. */
7
+ export function createTaskbarNotifier({ isEnabled, getWindowTitle }, launch = spawn) {
8
+ let active, disposed = false
9
+
10
+ function send(state, message) {
11
+ if (!state.child.stdin.destroyed) state.child.stdin.write(JSON.stringify(message) + '\n', 'utf8')
12
+ }
13
+ function stop(state = active) {
14
+ if (!state || state.stopping) return
15
+ state.stopping = true
16
+ send(state, { type: 'stop' })
17
+ state.killTimer = setTimeout(() => state.child.kill(), 2000)
18
+ }
19
+ function start() {
20
+ const child = launch('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
21
+ '-File', script], { windowsHide: true, shell: false, stdio: ['pipe', 'ignore', 'pipe'] })
22
+ const state = { child, subscribers: new Set(), stopping: false }
23
+ let resolve, reject, stderr = '', error, finished = false
24
+ state.done = new Promise((yes, no) => { resolve = yes; reject = no })
25
+ state.done.catch(() => {})
26
+ const finish = failure => {
27
+ if (finished) return
28
+ finished = true
29
+ clearTimeout(state.timeout)
30
+ clearTimeout(state.killTimer)
31
+ if (active === state) active = undefined
32
+ if (failure && !state.stopping && !disposed) reject(failure)
33
+ else resolve()
34
+ }
35
+ child.stderr.setEncoding('utf8')
36
+ child.stderr.on('data', value => { stderr = (stderr + value).slice(-2048) })
37
+ child.on('error', failure => finish(failure.code === 'ENOENT'
38
+ ? new Error('找不到 pwsh.exe,请安装 PowerShell 7 并重启 DSH。') : failure))
39
+ child.on('close', code => finish(error ?? (code === 0 ? undefined : new Error(`任务栏提醒失败:${stderr.trim() || code}`))))
40
+ child.stdin.on('error', failure => { error = failure; child.kill() })
41
+ state.armDeadline = () => {
42
+ clearTimeout(state.timeout)
43
+ state.timeout = setTimeout(() => { error = new Error('任务栏提醒进程超时'); child.kill() }, 30_000)
44
+ }
45
+ state.armDeadline()
46
+ send(state, { hostPid: process.pid, windowTitle: getWindowTitle(), durationSeconds: 10 })
47
+ return state
48
+ }
49
+ function subscribe(state, signal) {
50
+ return new Promise((resolve, reject) => {
51
+ const subscriber = {}
52
+ state.subscribers.add(subscriber)
53
+ const abort = () => {
54
+ state.subscribers.delete(subscriber)
55
+ resolve()
56
+ if (!state.subscribers.size) stop(state)
57
+ }
58
+ signal?.addEventListener('abort', abort, { once: true })
59
+ state.done.then(resolve, reject).finally(() => {
60
+ state.subscribers.delete(subscriber)
61
+ signal?.removeEventListener('abort', abort)
62
+ })
63
+ })
64
+ }
65
+ function notify({ signal } = {}) {
66
+ if (disposed || !isEnabled() || signal?.aborted) return Promise.resolve()
67
+ if (active?.stopping) return active.done.catch(() => {}).then(() => notify({ signal }))
68
+ if (active) { active.armDeadline(); send(active, { type: 'refresh' }) }
69
+ else active = start()
70
+ return subscribe(active, signal)
71
+ }
72
+ return {
73
+ notify,
74
+ stop: () => stop(),
75
+ dispose() { disposed = true; stop() },
76
+ }
77
+ }
@@ -0,0 +1,81 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
2
+
3
+ /** Register live events only: restored history and initially idle agents do not notify. */
4
+ export function attachReminder(ctx, notify, reportError) {
5
+ const activities = new WeakMap()
6
+ const closedAgents = new WeakSet()
7
+ const notifications = new Map()
8
+ let disposed = false
9
+ const isMain = session => session.header.origin !== 'subagent'
10
+ && !(session.header.delegationDepth > 0)
11
+
12
+ ctx.on('agent/status', ({ agent, status }) => {
13
+ const session = agent.session
14
+ if (disposed || !isMain(session)) return
15
+ if (status === 'running') {
16
+ if (!activities.has(session)) activities.set(session, { completed: false, text: '' })
17
+ return
18
+ }
19
+ if (status !== 'idle') return
20
+ const activity = activities.get(session)
21
+ activities.delete(session)
22
+ if (!activity?.completed) return
23
+ const controller = new AbortController()
24
+ const pending = notifications.get(agent) ?? new Set()
25
+ pending.add(controller)
26
+ notifications.set(agent, pending)
27
+ // Consume before launching the subprocess so repeated idle events cannot duplicate it.
28
+ try {
29
+ Promise.resolve(notify({
30
+ sessionId: session.id, completedText: activity.text,
31
+ route: agent.model?.current(), signal: controller.signal,
32
+ onReply(text) {
33
+ if (disposed || closedAgents.has(agent) || agent.session !== session) {
34
+ throw new Error('原会话已关闭或切换,请回到 DSH 回复。')
35
+ }
36
+ return agent.followup(createUserMessage({
37
+ content: [{ type: 'text', text }], source: { kind: 'user' },
38
+ }))
39
+ },
40
+ })).catch(error => {
41
+ if (!disposed && !controller.signal.aborted) reportError(error)
42
+ }).finally(() => {
43
+ pending.delete(controller)
44
+ if (!pending.size) notifications.delete(agent)
45
+ })
46
+ } catch (error) {
47
+ pending.delete(controller)
48
+ if (!pending.size) notifications.delete(agent)
49
+ reportError(error)
50
+ }
51
+ })
52
+
53
+ ctx.on('session/event', (session, event) => {
54
+ if (disposed) return
55
+ const activity = activities.get(session)
56
+ if (!activity) return
57
+ if (event.type === 'turn/start') {
58
+ activity.completed = false
59
+ activity.text = ''
60
+ }
61
+ if (event.type === 'assistant/message') {
62
+ const content = event.data.message.content
63
+ // Keep only the final, uninterrupted user-visible answer; never reasoning/tool payloads.
64
+ activity.text = event.data.interrupted || content.some(block => block.type === 'tool-call')
65
+ ? '' : content.filter(block => block.type === 'text').map(block => block.text).join('\n')
66
+ }
67
+ if (event.type === 'turn/end') activity.completed = event.data.reason.kind === 'completed'
68
+ })
69
+ ctx.on('agent/error', ({ agent }) => activities.delete(agent.session))
70
+ ctx.on('agent/disposed', ({ agent }) => {
71
+ activities.delete(agent.session)
72
+ closedAgents.add(agent)
73
+ for (const controller of notifications.get(agent) ?? []) controller.abort()
74
+ notifications.delete(agent)
75
+ })
76
+ ctx.effect(() => () => {
77
+ disposed = true
78
+ for (const pending of notifications.values()) for (const controller of pending) controller.abort()
79
+ notifications.clear()
80
+ })
81
+ }