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.
Files changed (51) hide show
  1. package/AGENTS.md +9 -1
  2. package/README.md +56 -16
  3. package/docs/interface.md +69 -6
  4. package/docs/remote-access.en.md +7 -5
  5. package/docs/remote-access.md +8 -6
  6. package/dsh-push.mjs +272 -64
  7. package/lib/client.js +550 -28
  8. package/lib/lan-gate-server.cjs +7 -1
  9. package/lib/types/client/MobileHome.d.ts.map +1 -1
  10. package/lib/types/client/MobileSessionHeader.d.ts +1 -1
  11. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  12. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  13. package/lib/types/client/effects/modal-back.d.ts +22 -0
  14. package/lib/types/client/effects/modal-back.d.ts.map +1 -0
  15. package/lib/types/client/history-nav.d.ts +49 -0
  16. package/lib/types/client/history-nav.d.ts.map +1 -0
  17. package/lib/types/client/index.d.ts.map +1 -1
  18. package/lib/types/client/locales.d.ts +4 -2
  19. package/lib/types/client/locales.d.ts.map +1 -1
  20. package/lib/types/client/styles/base.css.d.ts +1 -1
  21. package/lib/types/client/styles/base.css.d.ts.map +1 -1
  22. package/lib/types/client/styles/chips.css.d.ts +1 -1
  23. package/lib/types/client/styles/chips.css.d.ts.map +1 -1
  24. package/lib/types/client/styles/compat.css.d.ts +1 -1
  25. package/lib/types/client/styles/compat.css.d.ts.map +1 -1
  26. package/lib/types/client/styles/composer.css.d.ts +1 -1
  27. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  28. package/lib/types/client/styles/content.css.d.ts +1 -1
  29. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  30. package/lib/types/client/styles/header.css.d.ts +1 -1
  31. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  32. package/lib/types/client/styles/home.css.d.ts +1 -1
  33. package/lib/types/client/styles/home.css.d.ts.map +1 -1
  34. package/llms.txt +1 -1
  35. package/package.json +23 -21
  36. package/pwa/inject.js +1 -0
  37. package/scripts/sync-doc-version.mjs +83 -0
  38. package/src/client/MobileHome.tsx +17 -1
  39. package/src/client/MobileSessionHeader.tsx +134 -1
  40. package/src/client/MobileSessionInfo.tsx +38 -8
  41. package/src/client/effects/modal-back.ts +98 -0
  42. package/src/client/history-nav.ts +121 -0
  43. package/src/client/index.tsx +2 -0
  44. package/src/client/locales.ts +8 -4
  45. package/src/client/styles/base.css.ts +6 -0
  46. package/src/client/styles/chips.css.ts +4 -1
  47. package/src/client/styles/compat.css.ts +8 -1
  48. package/src/client/styles/composer.css.ts +25 -0
  49. package/src/client/styles/content.css.ts +24 -0
  50. package/src/client/styles/header.css.ts +123 -0
  51. 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 — agent-done push host plugin (OPTIONAL)
1
+ // dsh-zen-remote · dsh-push.mjs — mobile push host plugin (OPTIONAL)
2
2
  //
3
- // Sends a mobile push notification (via the gateway's local /pwa/push/send)
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
- // - inject: [] — uses only the Cordis event bus (ctx.on), no services, so
8
- // 0811 strict injection can never block loading. The push_notify tool
9
- // registration below fetches the "tools" service via ctx.get('tools')
10
- // (not a hard inject) and no-ops when it isn't present.
11
- // - Event names are configurable: DSH_PUSH_EVENTS (comma-separated).
12
- // Default "agent/turn-stopping" — the official turn-close checkpoint
13
- // ("the turn is about to close: the model owes no response"), payload
14
- // { agent, turn, signal }, per deepseek-harness docs/subsystems/core
15
- // and the scoped-events catalog. An unknown name simply never fires.
16
- // - Debounced: at most one notification per DSH_PUSH_DEBOUNCE_MS (default
17
- // 15s), so event bursts produce a single nudge.
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 assistant message
20
- // (truncated). The payload is aes128gcm-encrypted end-to-end, so the
21
- // push service (FCM/APNs/Mozilla) only ever sees ciphertext — the
22
- // remaining exposure is your own lock screen / notification center.
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). Explicit env wins.
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 === true || FILE.pushSummary === 1 || FILE.pushSummary === '1'
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
- // Low-level sender shared by the turn-close notify() below and the
52
- // push_notify tool: POSTs to the gateway's local /pwa/push/send, which does
53
- // the actual VAPID + aes128gcm encrypted delivery to every subscribed
54
- // device and replies { ok, sent, failed }.
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
- // AssistantMessage content may be a plain string or an array of parts.
66
- function messageText(message) {
67
- if (!message) return ''
68
- const c = message.content !== undefined ? message.content : message
69
- if (typeof c === 'string') return c
70
- if (Array.isArray(c)) {
71
- return c.map((p) => (typeof p === 'string' ? p : (p && typeof p.text === 'string' ? p.text : ''))).join(' ').trim()
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
- // Last `assistant/message` of the closing turn, from the session's
77
- // append-only event log (agent.session.events, see docs/subsystems/session).
78
- function turnSummary(payload) {
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 events = payload && payload.agent && payload.agent.session && payload.agent.session.events
81
- if (!events || !events.length) return ''
82
- for (let i = events.length - 1; i >= 0; i--) {
83
- const ev = events[i]
84
- if (ev && ev.type === 'assistant/message' && ev.data && (payload.turn === undefined || ev.data.turn === payload.turn)) {
85
- return messageText(ev.data.message).replace(/\s+/g, ' ').trim().slice(0, 120)
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
- } catch (e) { /* summary is best-effort */ }
89
- return ''
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: 'Push a notification to the user\'s phone lock screen via the DSH mobile PWA. Reserve this for moments that genuinely need the user\'s attention away from the screen: a decision is required to continue a long-running task, a significant milestone just completed, or an error needs human intervention before you can proceed. Do NOT call this for routine progress updates, minor sub-steps, or anything the user can find by just re-opening the chat — pushes are throttled (at most 1 per 60 seconds in this session, 20 total per hour across all sessions) specifically to keep them meaningful; calling it too often gets it silently dropped instead of sent. `title` must be a short, complete sentence that fits on one lock-screen line; `body` is optional extra detail shown when the notification is expanded. Delivery is end-to-end encrypted (aes128gcm) — the push provider (FCM/APNs/Mozilla) only ever sees ciphertext, and the only exposure is the phone\'s own lock screen / notification center. Sends to every device the user has paired and subscribed; returns how many actually received it, or throttled:true if the rate limit dropped the call before sending.',
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
- let lastSent = 0
345
+ const cfg = { turnEndEnabled: TURN_END_ENABLED, debounceMs: DEBOUNCE_MS, includeSummary: INCLUDE_SUMMARY }
193
346
 
194
- const notify = (payload) => {
195
- const now = Date.now()
196
- if (now - lastSent < DEBOUNCE_MS) return
197
- lastSent = now
198
- const summary = INCLUDE_SUMMARY ? turnSummary(payload) : ''
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('DSH 任务完成', summary || '智能体已完成当前回合').catch(() => {})
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, notify)
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
- registerPushTool(ctx)
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
  }