dsh-zen-remote 1.0.2 → 1.1.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/AGENTS.md +9 -1
- package/README.md +56 -16
- package/docs/interface.md +69 -6
- package/docs/remote-access.en.md +7 -5
- package/docs/remote-access.md +8 -6
- package/dsh-push.mjs +272 -64
- package/lib/client.js +550 -28
- package/lib/lan-gate-server.cjs +7 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/modal-back.d.ts +22 -0
- package/lib/types/client/effects/modal-back.d.ts.map +1 -0
- package/lib/types/client/history-nav.d.ts +49 -0
- package/lib/types/client/history-nav.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/styles/base.css.d.ts +1 -1
- package/lib/types/client/styles/base.css.d.ts.map +1 -1
- package/lib/types/client/styles/chips.css.d.ts +1 -1
- package/lib/types/client/styles/chips.css.d.ts.map +1 -1
- package/lib/types/client/styles/compat.css.d.ts +1 -1
- package/lib/types/client/styles/compat.css.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/content.css.d.ts +1 -1
- package/lib/types/client/styles/content.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/home.css.d.ts +1 -1
- package/lib/types/client/styles/home.css.d.ts.map +1 -1
- package/llms.txt +1 -1
- package/package.json +23 -21
- package/pwa/inject.js +1 -0
- package/scripts/sync-doc-version.mjs +83 -0
- package/src/client/MobileHome.tsx +17 -1
- package/src/client/MobileSessionHeader.tsx +134 -1
- package/src/client/MobileSessionInfo.tsx +38 -8
- package/src/client/effects/modal-back.ts +98 -0
- package/src/client/history-nav.ts +121 -0
- package/src/client/index.tsx +2 -0
- package/src/client/locales.ts +8 -4
- package/src/client/styles/base.css.ts +6 -0
- package/src/client/styles/chips.css.ts +4 -1
- package/src/client/styles/compat.css.ts +8 -1
- package/src/client/styles/composer.css.ts +25 -0
- package/src/client/styles/content.css.ts +24 -0
- package/src/client/styles/header.css.ts +123 -0
- package/src/client/styles/home.css.ts +19 -10
package/dsh-push.mjs
CHANGED
|
@@ -1,37 +1,41 @@
|
|
|
1
|
-
// dsh-zen-remote · dsh-push.mjs —
|
|
1
|
+
// dsh-zen-remote · dsh-push.mjs — mobile push host plugin (OPTIONAL)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// when a DSH agent finishes a turn, and (optionally) registers a
|
|
5
|
-
// model-callable push_notify tool. Deliberately minimal and defensive:
|
|
3
|
+
// Two-legged notification policy (docs/push-policy.md):
|
|
6
4
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
5
|
+
// Event leg (deterministic, always fires) — the things that BLOCK the user:
|
|
6
|
+
// · a tool approval is waiting on a human (session event `approval/asked`
|
|
7
|
+
// with no `approval/decided` within APPROVAL_PENDING_MS)
|
|
8
|
+
// · a question is waiting on a human (session event `tool/call` for
|
|
9
|
+
// `ask_user_question`)
|
|
10
|
+
// Model leg (judgement call) — the `push_notify` tool.
|
|
11
|
+
// Turn end is OPT-IN (DSH_PUSH_TURN_END=1). Finishing a turn is not by
|
|
12
|
+
// itself a reason to buzz someone's pocket.
|
|
13
|
+
//
|
|
14
|
+
// Deliberately minimal and defensive:
|
|
15
|
+
// - inject: [] — only the Cordis event bus (ctx.on), no hard service deps,
|
|
16
|
+
// so 0811 strict injection can never block loading. The push_notify tool
|
|
17
|
+
// and the system-prompt reinforcement are attached via ctx.inject() and
|
|
18
|
+
// no-op when the host provides no such service.
|
|
19
|
+
// - Only top-level sessions notify on turn end: the session header's
|
|
20
|
+
// `delegationDepth` is ABSENT for a top-level session and parent+1 for a
|
|
21
|
+
// subagent child (see @deepseek-ai/dsh-session SessionHeader), so the test
|
|
22
|
+
// is `(delegationDepth ?? 0) === 0`. Subagent turn ends never push.
|
|
23
|
+
// - Debounced (DSH_PUSH_DEBOUNCE_MS, default 15s) across the turn-end and
|
|
24
|
+
// model legs. Approval/question notifications are NEVER swallowed by it —
|
|
25
|
+
// those are exactly the ones you cannot afford to miss.
|
|
18
26
|
// - By default the notification carries NO conversation content. Set
|
|
19
|
-
// DSH_PUSH_SUMMARY=1 to include the turn's final
|
|
20
|
-
//
|
|
21
|
-
// push service (FCM/APNs/Mozilla) only ever sees
|
|
22
|
-
// remaining exposure is your own lock screen
|
|
23
|
-
// - push_notify (model tool, on by default): lets the agent itself ask for
|
|
24
|
-
// a push mid-task instead of only on turn-close. Set DSH_PUSH_TOOL=0 or
|
|
25
|
-
// {"pushTool": false} in lan-gate.config.json to turn it off. Same
|
|
26
|
-
// encrypted delivery path as above, throttled independently (see
|
|
27
|
-
// registerPushTool below) so a chatty model can't spam the lock screen.
|
|
27
|
+
// DSH_PUSH_SUMMARY=1 to include the turn's final TEXT output (reasoning
|
|
28
|
+
// excluded) or the pending question. The payload is aes128gcm-encrypted
|
|
29
|
+
// end-to-end, so the push service (FCM/APNs/Mozilla) only ever sees
|
|
30
|
+
// ciphertext — the remaining exposure is your own lock screen.
|
|
28
31
|
export const name = 'dsh-zen-remote-push'
|
|
29
32
|
export const inject = []
|
|
30
33
|
|
|
31
34
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
32
35
|
|
|
33
36
|
// Shares the gateway's optional config file <DSH_HOME>/lan-gate.config.json
|
|
34
|
-
// (keys: port, pushEvents, pushDebounceMs, pushSummary
|
|
37
|
+
// (keys: port, pushEvents, pushDebounceMs, pushSummary, pushTurnEnd, pushTool).
|
|
38
|
+
// Explicit env wins.
|
|
35
39
|
import { readFileSync } from 'node:fs'
|
|
36
40
|
import { join } from 'node:path'
|
|
37
41
|
import { homedir } from 'node:os'
|
|
@@ -41,17 +45,30 @@ function fileConfig() {
|
|
|
41
45
|
return raw !== null && typeof raw === 'object' ? raw : {}
|
|
42
46
|
} catch { return {} }
|
|
43
47
|
}
|
|
48
|
+
const truthy = (v) => v === true || v === 1 || v === '1'
|
|
44
49
|
const FILE = fileConfig()
|
|
45
50
|
const GATEWAY_PORT = Number(process.env.LAN_GATE_PORT ?? FILE.port ?? 3088)
|
|
46
51
|
const EVENTS = String(process.env.DSH_PUSH_EVENTS ?? FILE.pushEvents ?? 'agent/turn-stopping').split(',').map((s) => s.trim()).filter(Boolean)
|
|
47
52
|
const DEBOUNCE_MS = Number(process.env.DSH_PUSH_DEBOUNCE_MS ?? FILE.pushDebounceMs ?? 15000)
|
|
48
|
-
const INCLUDE_SUMMARY = process.env.DSH_PUSH_SUMMARY !== undefined ? process.env.DSH_PUSH_SUMMARY === '1' : FILE.pushSummary
|
|
53
|
+
const INCLUDE_SUMMARY = process.env.DSH_PUSH_SUMMARY !== undefined ? process.env.DSH_PUSH_SUMMARY === '1' : truthy(FILE.pushSummary)
|
|
54
|
+
const TURN_END_ENABLED = process.env.DSH_PUSH_TURN_END !== undefined ? process.env.DSH_PUSH_TURN_END === '1' : truthy(FILE.pushTurnEnd)
|
|
49
55
|
const PUSH_TOOL_ENABLED = process.env.DSH_PUSH_TOOL !== undefined ? process.env.DSH_PUSH_TOOL !== '0' : FILE.pushTool !== false
|
|
50
56
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// the
|
|
54
|
-
//
|
|
57
|
+
// The model-facing tool of @deepseek-ai/dsh-tool-ask-user. Its `tool/call`
|
|
58
|
+
// session event is appended BEFORE dispatch (dsh-agent-loop appendToolCall),
|
|
59
|
+
// and the call then blocks in ctx.userQuestions.ask() until a human answers —
|
|
60
|
+
// so the call event IS the "a question is pending" signal.
|
|
61
|
+
const ASK_USER_TOOL = 'ask_user_question'
|
|
62
|
+
|
|
63
|
+
// @deepseek-ai/dsh-user-approval appends `approval/asked` before consulting
|
|
64
|
+
// the answerer chain and `approval/decided` after. A policy-resolved or
|
|
65
|
+
// hook-answered request settles in the same tick, so only an ask still
|
|
66
|
+
// undecided after this grace period is actually waiting on a human.
|
|
67
|
+
const APPROVAL_PENDING_MS = 1500
|
|
68
|
+
|
|
69
|
+
// Low-level sender shared by every leg: POSTs to the gateway's local
|
|
70
|
+
// /pwa/push/send, which does the actual VAPID + aes128gcm encrypted delivery
|
|
71
|
+
// to every subscribed device and replies { ok, sent, failed }.
|
|
55
72
|
async function sendPush(title, body) {
|
|
56
73
|
const res = await fetch(`http://127.0.0.1:${GATEWAY_PORT}/pwa/push/send`, {
|
|
57
74
|
method: 'POST',
|
|
@@ -62,33 +79,149 @@ async function sendPush(title, body) {
|
|
|
62
79
|
return res.json()
|
|
63
80
|
}
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
const SUMMARY_MAX = 120
|
|
83
|
+
const clip = (s) => String(s).replace(/\s+/g, ' ').trim().slice(0, SUMMARY_MAX)
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Pure decision layer. Everything below this line up to `apply()` is a pure
|
|
87
|
+
// function of its arguments — that is the only way to test push timing
|
|
88
|
+
// without creating a real DSH session (workspace hard rule). See
|
|
89
|
+
// test/push-policy.test.cjs.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
// Model-facing TEXT of one assistant message: `type: 'text'` blocks only.
|
|
93
|
+
// Reasoning arrives as `{ type: 'reasoning', text }` blocks on the very same
|
|
94
|
+
// message (@deepseek-ai/dsh-llm ContentBlockMap) — taking every part that
|
|
95
|
+
// happens to own a `.text` is what made DSH_PUSH_SUMMARY quote the model's
|
|
96
|
+
// thinking instead of its answer.
|
|
97
|
+
export function assistantText(message) {
|
|
98
|
+
const c = message && message.content !== undefined ? message.content : message
|
|
99
|
+
if (typeof c === 'string') return clip(c)
|
|
100
|
+
if (!Array.isArray(c)) return ''
|
|
101
|
+
const parts = []
|
|
102
|
+
for (const p of c) {
|
|
103
|
+
if (typeof p === 'string') parts.push(p)
|
|
104
|
+
else if (p && p.type === 'text' && typeof p.text === 'string') parts.push(p.text)
|
|
105
|
+
}
|
|
106
|
+
return clip(parts.join(' '))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Summary of the closing turn, from the session's append-only event log:
|
|
110
|
+
// the LAST assistant message that produced real text, or — when the whole
|
|
111
|
+
// turn was tool work with no prose — the last tool name. Never thinking text.
|
|
112
|
+
export function turnSummary(events, turn) {
|
|
113
|
+
if (!Array.isArray(events)) return ''
|
|
114
|
+
let lastTool = ''
|
|
115
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
116
|
+
const ev = events[i]
|
|
117
|
+
if (!ev || !ev.data) continue
|
|
118
|
+
// Stop at the turn boundary instead of reaching back into older turns.
|
|
119
|
+
if (turn !== undefined && ev.data.turn !== undefined && ev.data.turn !== turn) break
|
|
120
|
+
if (ev.type === 'assistant/message') {
|
|
121
|
+
const text = assistantText(ev.data.message)
|
|
122
|
+
if (text) return text
|
|
123
|
+
} else if (!lastTool && ev.type === 'tool/call' && typeof ev.data.name === 'string') {
|
|
124
|
+
lastTool = ev.data.name
|
|
125
|
+
}
|
|
72
126
|
}
|
|
73
|
-
return ''
|
|
127
|
+
return lastTool ? `最后执行了 ${lastTool}` : ''
|
|
74
128
|
}
|
|
75
129
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
function
|
|
130
|
+
// First question of an ask_user_question call, from the raw (unparsed)
|
|
131
|
+
// arguments string the model produced. Best-effort: garbage in, '' out.
|
|
132
|
+
export function pendingQuestionText(rawArguments) {
|
|
79
133
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
134
|
+
const q = JSON.parse(String(rawArguments)).questions
|
|
135
|
+
return Array.isArray(q) && q[0] && typeof q[0].question === 'string' ? clip(q[0].question) : ''
|
|
136
|
+
} catch { return '' }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const skip = (reason) => ({ shouldNotify: false, title: '', body: '', reason })
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The whole notification policy, as one pure function.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} input
|
|
145
|
+
* kind 'turn-end' | 'approval' | 'question'
|
|
146
|
+
* now current epoch ms
|
|
147
|
+
* lastSent epoch ms of the previous push (0 for none)
|
|
148
|
+
* delegationDepth session header's delegationDepth (undefined = top level)
|
|
149
|
+
* summary already-extracted turn summary ('turn-end')
|
|
150
|
+
* toolName tool awaiting approval ('approval')
|
|
151
|
+
* question pending question text ('question')
|
|
152
|
+
* @param {object} cfg { turnEndEnabled, debounceMs, includeSummary }
|
|
153
|
+
* @returns {{shouldNotify: boolean, title: string, body: string, reason: string}}
|
|
154
|
+
*/
|
|
155
|
+
export function decideNotification(input, cfg) {
|
|
156
|
+
const debounced = (input.now - input.lastSent) < cfg.debounceMs
|
|
157
|
+
|
|
158
|
+
switch (input.kind) {
|
|
159
|
+
// Event leg. Exempt from the debounce on purpose: "a tool is waiting for
|
|
160
|
+
// your OK" is the one notification that must never be swallowed.
|
|
161
|
+
case 'approval':
|
|
162
|
+
return {
|
|
163
|
+
shouldNotify: true,
|
|
164
|
+
title: 'DSH 等你授权',
|
|
165
|
+
body: input.toolName ? `${input.toolName} 需要授权才能继续` : '有操作需要授权才能继续',
|
|
166
|
+
reason: 'approval-pending'
|
|
86
167
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
168
|
+
case 'question':
|
|
169
|
+
return {
|
|
170
|
+
shouldNotify: true,
|
|
171
|
+
title: 'DSH 等你回答',
|
|
172
|
+
body: (cfg.includeSummary && input.question) || '智能体提了一个问题,正在等你回答',
|
|
173
|
+
reason: 'question-pending'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Turn end. Opt-in, top-level only, debounced.
|
|
177
|
+
case 'turn-end':
|
|
178
|
+
if (!cfg.turnEndEnabled) return skip('turn-end-disabled')
|
|
179
|
+
if ((input.delegationDepth ?? 0) !== 0) return skip('subagent')
|
|
180
|
+
if (debounced) return skip('debounced')
|
|
181
|
+
return { shouldNotify: true, title: 'DSH 任务完成', body: input.summary || '智能体已完成当前回合', reason: 'turn-end' }
|
|
182
|
+
|
|
183
|
+
default:
|
|
184
|
+
return skip('unknown-kind')
|
|
185
|
+
}
|
|
90
186
|
}
|
|
91
187
|
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// push_notify (model leg)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
// THE single source of "when should you notify me". Both the tool description
|
|
193
|
+
// and the system-prompt reinforcement below embed this exact string, so the
|
|
194
|
+
// two can never drift apart. Listing only the when-to-call half is what turns
|
|
195
|
+
// push_notify into a per-turn reflex — the when-NOT-to half is load-bearing.
|
|
196
|
+
export const PUSH_NOTIFY_GUIDANCE =
|
|
197
|
+
'Notify the user when: (1) they asked to be told once something is finished or a result is ready; ' +
|
|
198
|
+
'(2) you cannot go on without them — a question that needs answering, an operation that needs ' +
|
|
199
|
+
'authorization, a call only they can make; (3) something happened they almost certainly want to know ' +
|
|
200
|
+
'immediately — the task failed, or you hit a blocker you cannot route around. ' +
|
|
201
|
+
'Do NOT call this for: an ordinary end of turn, progress or status reports, intermediate milestones, ' +
|
|
202
|
+
'or anything you can keep making headway on by yourself. Finishing a turn is not by itself a reason ' +
|
|
203
|
+
'to buzz someone\'s phone.'
|
|
204
|
+
|
|
205
|
+
const PUSH_NOTIFY_DESCRIPTION =
|
|
206
|
+
'Push a notification to the user\'s phone lock screen via the DSH mobile PWA. ' +
|
|
207
|
+
PUSH_NOTIFY_GUIDANCE +
|
|
208
|
+
' Pushes are throttled (at most 1 per 60 seconds in this session, 20 total per hour across all sessions) ' +
|
|
209
|
+
'specifically to keep them meaningful; calling it too often gets it silently dropped instead of sent. ' +
|
|
210
|
+
'`title` must be a short, complete sentence that fits on one lock-screen line; `body` is optional extra ' +
|
|
211
|
+
'detail shown when the notification is expanded. Delivery is end-to-end encrypted (aes128gcm) — the push ' +
|
|
212
|
+
'provider (FCM/APNs/Mozilla) only ever sees ciphertext, and the only exposure is the phone\'s own lock ' +
|
|
213
|
+
'screen / notification center. Sends to every device the user has paired and subscribed; returns how many ' +
|
|
214
|
+
'actually received it, or throttled:true if the rate limit dropped the call before sending.'
|
|
215
|
+
|
|
216
|
+
// Same guidance, restated as standing context: a tool description sitting far
|
|
217
|
+
// up a long conversation gets diluted, and the failure mode that costs the
|
|
218
|
+
// user is a model that quietly stops notifying at all.
|
|
219
|
+
export const PUSH_NOTIFY_SECTION =
|
|
220
|
+
'Reaching the user away from the screen: this deployment can push to the user\'s phone lock screen ' +
|
|
221
|
+
'with the `push_notify` tool. ' + PUSH_NOTIFY_GUIDANCE +
|
|
222
|
+
' The harness already pushes on its own whenever a tool is waiting for authorization or an ' +
|
|
223
|
+
'`ask_user_question` is unanswered, so you never need `push_notify` for those two.'
|
|
224
|
+
|
|
92
225
|
// Rate limits for the push_notify tool. Fixed on purpose (not config knobs)
|
|
93
226
|
// — these exist to keep pushes meaningful, not to be tuned per-deployment.
|
|
94
227
|
const PUSH_TOOL_SESSION_WINDOW_MS = 60_000
|
|
@@ -98,7 +231,7 @@ const PUSH_TOOL_GLOBAL_MAX = 20
|
|
|
98
231
|
// Registers the push_notify model tool against ctx.tools, if present and not
|
|
99
232
|
// disabled. Throttle state lives in this closure (fresh per apply() call, so
|
|
100
233
|
// tests get isolated state without needing a fresh module import).
|
|
101
|
-
function registerPushTool(ctx) {
|
|
234
|
+
function registerPushTool(ctx, gate) {
|
|
102
235
|
if (!PUSH_TOOL_ENABLED) {
|
|
103
236
|
console.log('[dsh-zen-remote-push] push_notify disabled (DSH_PUSH_TOOL=0 / pushTool:false)')
|
|
104
237
|
return
|
|
@@ -109,10 +242,22 @@ function registerPushTool(ctx) {
|
|
|
109
242
|
// registered. inject() defers the callback until the service exists (the
|
|
110
243
|
// same pattern vision-toolkit uses for webServer) and still degrades
|
|
111
244
|
// gracefully: hosts without a tools service simply never fire it.
|
|
112
|
-
ctx.inject(['tools'], (toolsCtx) => registerPushToolWith(toolsCtx, toolsCtx.tools))
|
|
245
|
+
ctx.inject(['tools'], (toolsCtx) => registerPushToolWith(toolsCtx, toolsCtx.tools, gate))
|
|
246
|
+
|
|
247
|
+
// Standing reinforcement of the same guidance. Order 150 is the documented
|
|
248
|
+
// tool-guidance band (@deepseek-ai/dsh-system-prompt PromptSection.order).
|
|
249
|
+
ctx.inject(['systemPrompt'], (promptCtx) => {
|
|
250
|
+
if (!promptCtx.systemPrompt) return
|
|
251
|
+
try {
|
|
252
|
+
promptCtx.systemPrompt.section({ name: 'dsh-zen-remote-push', order: 150, text: PUSH_NOTIFY_SECTION })
|
|
253
|
+
console.log('[dsh-zen-remote-push] notification guidance added to the system prompt')
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.warn(`[dsh-zen-remote-push] cannot register prompt section: ${String(e && e.message || e)}`)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
113
258
|
}
|
|
114
259
|
|
|
115
|
-
function registerPushToolWith(ctx, tools) {
|
|
260
|
+
function registerPushToolWith(ctx, tools, gate) {
|
|
116
261
|
if (!tools) {
|
|
117
262
|
console.log('[dsh-zen-remote-push] "tools" service not present — push_notify not registered')
|
|
118
263
|
return
|
|
@@ -134,7 +279,7 @@ function registerPushToolWith(ctx, tools) {
|
|
|
134
279
|
|
|
135
280
|
ctx.effect(() => tools.register(defineTool({
|
|
136
281
|
name: 'push_notify',
|
|
137
|
-
description:
|
|
282
|
+
description: PUSH_NOTIFY_DESCRIPTION,
|
|
138
283
|
parameters: {
|
|
139
284
|
title: {
|
|
140
285
|
type: 'string',
|
|
@@ -176,6 +321,12 @@ function registerPushToolWith(ctx, tools) {
|
|
|
176
321
|
const now = Date.now()
|
|
177
322
|
if (isThrottled(sessionId, now)) return { delivered: 0, throttled: true }
|
|
178
323
|
reserve(sessionId, now)
|
|
324
|
+
// Counts toward the shared debounce clock, so an automatic turn-end
|
|
325
|
+
// push landing right behind this one is suppressed. Not gated BY it:
|
|
326
|
+
// this leg's own 1-per-60s-per-session limit is already stricter than
|
|
327
|
+
// the 15s window, and stacking both would only make the tool lie about
|
|
328
|
+
// why it dropped a call.
|
|
329
|
+
gate.arm(now)
|
|
179
330
|
try {
|
|
180
331
|
const result = await sendPush(args.title, args.body)
|
|
181
332
|
return { delivered: (result && typeof result.sent === 'number') ? result.sent : 0 }
|
|
@@ -188,26 +339,83 @@ function registerPushToolWith(ctx, tools) {
|
|
|
188
339
|
console.log('[dsh-zen-remote-push] push_notify tool registered')
|
|
189
340
|
}
|
|
190
341
|
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
191
344
|
export function apply(ctx) {
|
|
192
|
-
|
|
345
|
+
const cfg = { turnEndEnabled: TURN_END_ENABLED, debounceMs: DEBOUNCE_MS, includeSummary: INCLUDE_SUMMARY }
|
|
193
346
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
347
|
+
// The one piece of mutable state: when the last push went out. Wrapped so
|
|
348
|
+
// every leg runs the same pure decision and shares the same debounce clock.
|
|
349
|
+
let lastSent = 0
|
|
350
|
+
const gate = {
|
|
351
|
+
decide(input) {
|
|
352
|
+
const decision = decideNotification({ ...input, now: Date.now(), lastSent }, cfg)
|
|
353
|
+
if (decision.shouldNotify) lastSent = Date.now()
|
|
354
|
+
return decision
|
|
355
|
+
},
|
|
356
|
+
arm(now) { lastSent = now }
|
|
357
|
+
}
|
|
358
|
+
const fire = (input) => {
|
|
359
|
+
const decision = gate.decide(input)
|
|
199
360
|
// Best-effort; never raise into the host.
|
|
200
|
-
sendPush(
|
|
361
|
+
if (decision.shouldNotify) sendPush(decision.title, decision.body).catch(() => {})
|
|
362
|
+
return decision
|
|
201
363
|
}
|
|
202
364
|
|
|
365
|
+
// --- Turn-end leg (opt-in) -----------------------------------------------
|
|
366
|
+
const onTurnEnd = (payload) => {
|
|
367
|
+
const session = payload && payload.agent && payload.agent.session
|
|
368
|
+
const header = session && session.header
|
|
369
|
+
// An ABSENT delegationDepth on a PRESENT header means top level, and the
|
|
370
|
+
// decision function reads it that way. A missing header is a different
|
|
371
|
+
// thing entirely — "could not tell whose turn this was" — and passing its
|
|
372
|
+
// undefined through would be read as top level too, failing OPEN and
|
|
373
|
+
// letting subagent turn ends back in. Turn-end is the low-value leg, so
|
|
374
|
+
// when the session cannot be identified, stay quiet.
|
|
375
|
+
if (!header) return
|
|
376
|
+
fire({
|
|
377
|
+
kind: 'turn-end',
|
|
378
|
+
delegationDepth: header && header.delegationDepth,
|
|
379
|
+
summary: INCLUDE_SUMMARY ? turnSummary(session && session.events, payload && payload.turn) : ''
|
|
380
|
+
})
|
|
381
|
+
}
|
|
203
382
|
for (const event of EVENTS) {
|
|
204
383
|
try {
|
|
205
|
-
ctx.on(event,
|
|
384
|
+
ctx.on(event, onTurnEnd)
|
|
206
385
|
} catch (e) {
|
|
207
386
|
console.warn(`[dsh-zen-remote-push] cannot listen on "${event}": ${String(e && e.message || e)}`)
|
|
208
387
|
}
|
|
209
388
|
}
|
|
210
|
-
console.log(`[dsh-zen-remote-push] listening for: ${EVENTS.join(', ')} (set DSH_PUSH_EVENTS to adjust)`)
|
|
211
389
|
|
|
212
|
-
|
|
390
|
+
// --- Event leg: approvals and questions ----------------------------------
|
|
391
|
+
// `session/event` is the post-commit append feed for EVERY session, subagent
|
|
392
|
+
// children included — those notify through here, they just never notify on
|
|
393
|
+
// turn end.
|
|
394
|
+
const armed = new Map() // ApprovalRequestId -> grace timer
|
|
395
|
+
try {
|
|
396
|
+
ctx.on('session/event', (_session, event) => {
|
|
397
|
+
if (!event || !event.data) return
|
|
398
|
+
if (event.type === 'approval/asked') {
|
|
399
|
+
const id = event.data.id
|
|
400
|
+
const toolName = event.data.toolName
|
|
401
|
+
const timer = setTimeout(() => {
|
|
402
|
+
armed.delete(id)
|
|
403
|
+
fire({ kind: 'approval', toolName })
|
|
404
|
+
}, APPROVAL_PENDING_MS)
|
|
405
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
406
|
+
armed.set(id, timer)
|
|
407
|
+
} else if (event.type === 'approval/decided') {
|
|
408
|
+
const timer = armed.get(event.data.id)
|
|
409
|
+
if (timer !== undefined) { clearTimeout(timer); armed.delete(event.data.id) }
|
|
410
|
+
} else if (event.type === 'tool/call' && event.data.name === ASK_USER_TOOL) {
|
|
411
|
+
fire({ kind: 'question', question: pendingQuestionText(event.data.arguments) })
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
} catch (e) {
|
|
415
|
+
console.warn(`[dsh-zen-remote-push] cannot listen on "session/event": ${String(e && e.message || e)}`)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
console.log(`[dsh-zen-remote-push] approval/question notifications on; turn-end (${EVENTS.join(', ')}) ${TURN_END_ENABLED ? 'on' : 'off — set DSH_PUSH_TURN_END=1 to enable'}`)
|
|
419
|
+
|
|
420
|
+
registerPushTool(ctx, gate)
|
|
213
421
|
}
|