@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.
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/README.zh-CN.md +152 -0
- package/assets/DEEPSEEK-LICENSE.txt +21 -0
- package/assets/README.md +9 -0
- package/assets/deepseek-harness.png +0 -0
- package/cordis.patch.yml +3 -0
- package/example.patch.yml +9 -0
- package/lib/client.js +246 -0
- package/lib/index.js +1 -0
- package/package.json +75 -4
- package/scripts/test-popup.js +19 -0
- package/scripts/test-taskbar.js +11 -0
- package/src/index.js +42 -0
- package/src/modules/popup/ToastActivator.cs +153 -0
- package/src/modules/popup/ToastIdentity.cs +71 -0
- package/src/modules/popup/activate.ps1 +5 -0
- package/src/modules/popup/approval.js +60 -0
- package/src/modules/popup/index.js +103 -0
- package/src/modules/popup/open.js +33 -0
- package/src/modules/popup/queue.js +93 -0
- package/src/modules/popup/reply.js +96 -0
- package/src/modules/popup/summary.js +80 -0
- package/src/modules/popup/toast.js +110 -0
- package/src/modules/popup/toast.ps1 +110 -0
- package/src/modules/taskbar/badge.ps1 +237 -0
- package/src/modules/taskbar/index.js +22 -0
- package/src/modules/taskbar/windows.js +80 -0
- package/src/reminder.js +93 -0
|
@@ -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
|
+
}
|
package/src/reminder.js
ADDED
|
@@ -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
|
+
}
|