@kesike/reminder-dsh 0.0.0-stage → 0.2.1

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