dsh-lite-pet 0.2.1 → 0.2.3

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/index.js CHANGED
@@ -33,6 +33,9 @@ const ENABLED_ROUTE = '/api/dsh-lite-pet/enabled'
33
33
  const PUSH_INTERVAL_MS = 250
34
34
  const BUBBLE_TEXT_LIMIT = 120
35
35
  const BALANCE_VISIBLE_MS = 9000
36
+ // Backstop for the "completed" bubble. Clicking the pet clears it immediately; this only
37
+ // stops a forgotten message from sitting there for the rest of the session.
38
+ const DONE_VISIBLE_MS = 12 * 60_000
36
39
  const RESTART_DELAY_MS = 4000
37
40
  const MAX_RESTARTS = 3
38
41
 
@@ -313,7 +316,22 @@ function handlePetLine(ctx, runtime, profile, message) {
313
316
  return
314
317
  }
315
318
  if (message?.t === 'open') {
316
- openHarness(ctx, runtime)
319
+ // Clicking the pet is the user acknowledging the result: the completed bubble has
320
+ // done its job once they have gone looking. Clearing it here also means a later
321
+ // "done" from another session is the only thing that can put it back, instead of
322
+ // a twelve-minute-old message reappearing after every click.
323
+ runtime.doneText = null
324
+ runtime.doneAt = 0
325
+ runtime.lastSent = ''
326
+ // The pet tries first: it can show or restore the window on its own, and only if it
327
+ // finds the window hidden does it need the shell reached from this process.
328
+ void openHarness(ctx, runtime)
329
+ pushState(ctx, runtime, profile)
330
+ return
331
+ }
332
+ if (message?.t === 'hidden') {
333
+ // Sent after the pet's own attempt failed, so the cheap routes are already exhausted.
334
+ void reopenHidden(ctx, runtime)
317
335
  return
318
336
  }
319
337
  if (message?.t === 'balance') {
@@ -495,28 +513,42 @@ function pushState(ctx, runtime, profile) {
495
513
  const now = Date.now()
496
514
  let mode = 'idle'
497
515
  let text
498
- let touched = -1
499
516
  let waitingAgent = null
500
517
  let runningAgent = null
518
+ // Each direction compares against its own timestamp. Sharing one accumulator made the
519
+ // running candidate inherit the waiting candidate's timestamp, so with no session
520
+ // waiting the accumulator stayed at its initial value and every running session passed
521
+ // the comparison — the last one inserted into the map won, whatever its actual activity.
522
+ // The bubble then narrated a session the user had already left.
523
+ let waitingTouched = -1
524
+ let runningTouched = -1
501
525
  for (const [agentId, state] of runtime.agents) {
502
526
  if (now - state.touched > 60_000) continue
503
- if (state.waiting > 0 && (waitingAgent === null || state.touched > touched)) {
527
+ if (state.waiting > 0 && state.touched > waitingTouched) {
504
528
  waitingAgent = agentId
505
- touched = state.touched
529
+ waitingTouched = state.touched
506
530
  }
507
- if (state.running && (runningAgent === null || state.touched > touched)) {
531
+ if (state.running && state.touched > runningTouched) {
508
532
  runningAgent = agentId
533
+ runningTouched = state.touched
509
534
  }
510
535
  }
536
+ // Priority, highest first: waiting (yellow) > done (green) > working (plain).
537
+ //
538
+ // The green completion state outranks working on purpose. A session that just finished is
539
+ // the thing the user is waiting to hear about, and it would otherwise be drowned out by any
540
+ // other session that happens to be busy — which is the common case with several chats open.
541
+ // It stays below waiting because a pending decision blocks that session and demands
542
+ // attention.
511
543
  if (waitingAgent !== null) {
512
544
  mode = 'waiting'
513
545
  text = bubbleText(runtime.agents.get(waitingAgent).text) || '需要你确认一下'
546
+ } else if (runtime.doneText !== null && now - runtime.doneAt < DONE_VISIBLE_MS) {
547
+ mode = 'done'
548
+ text = runtime.doneText
514
549
  } else if (runningAgent !== null) {
515
550
  mode = 'working'
516
551
  text = bubbleText(runtime.agents.get(runningAgent).text) || '正在工作…'
517
- } else if (runtime.doneText !== null && now - runtime.doneAt < 12 * 60_000) {
518
- mode = 'done'
519
- text = runtime.doneText
520
552
  }
521
553
 
522
554
  const payload = JSON.stringify({ t: 'state', mode, text })
@@ -579,56 +611,137 @@ async function sendBalance(ctx, runtime, profile) {
579
611
  sendPet(runtime, { t: 'state', mode: 'balance', text, level })
580
612
  }
581
613
 
582
- // ——— open the Harness window ——————————————————————————————————————————
583
-
584
- let protocolChecked
585
- function desktopProtocolRegistered() {
586
- if (protocolChecked !== undefined) return protocolChecked
587
- try {
588
- const probe = spawn('reg.exe', ['query', 'HKCU\\Software\\Classes\\dsh', '/ve'], { windowsHide: true })
589
- let out = ''
590
- probe.stdout.setEncoding('utf8')
591
- probe.stdout.on('data', (chunk) => {
592
- out += chunk
593
- })
594
- probe.on('close', () => {
595
- protocolChecked = out.includes('URL:dsh')
596
- })
597
- probe.on('error', () => {
598
- protocolChecked = false
599
- })
600
- return true // optimistic for the first click; the probe settles quickly
601
- } catch {
602
- protocolChecked = false
603
- return false
614
+ // ——— reopen the desktop window ————————————————————————————————————————
615
+
616
+ // Ask the desktop shell to show its window by clicking its own tray icon.
617
+ //
618
+ // This runs in the plugin process, not in the pet, and that is the whole point. The
619
+ // sandbox starts the pet at low integrity; a low-integrity process is blocked by UIPI
620
+ // from reading the taskbar's XAML automation tree, so it sees five empty children with
621
+ // no tray buttons and every attempt from there fails silently. The plugin host runs at
622
+ // medium integrity, where the same query returns all thirty-one elements and the icon
623
+ // is invocable. Measured on this machine, both ways.
624
+ //
625
+ // PowerShell is used as the automation host because it ships with Windows, inherits
626
+ // this process's integrity, and keeps the plugin free of a compiled helper that would
627
+ // have to be built and shipped alongside it.
628
+ const TRAY_CLICK_SCRIPT = `
629
+ Add-Type -AssemblyName UIAutomationClient
630
+ Add-Type -AssemblyName UIAutomationTypes
631
+ $AE = [System.Windows.Automation.AutomationElement]
632
+ $cond = New-Object System.Windows.Automation.PropertyCondition($AE::ClassNameProperty, 'Shell_TrayWnd')
633
+ $bar = $AE::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
634
+ if (-not $bar) { exit 2 }
635
+ $all = $bar.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
636
+ foreach ($e in $all) {
637
+ try { $cls = $e.Current.ClassName } catch { continue }
638
+ if ($cls -notlike 'SystemTray.*') { continue }
639
+ try { $nm = $e.Current.Name } catch { continue }
640
+ if ($nm.Trim() -like 'DeepSeek Harness*') {
641
+ $inv = $e.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)
642
+ $inv.Invoke()
643
+ exit 0
604
644
  }
605
645
  }
606
-
607
- function openHarness(ctx, runtime) {
608
- const url = (() => {
646
+ exit 1
647
+ `
648
+
649
+ function clickTrayIcon() {
650
+ return new Promise((resolve) => {
651
+ const powershell = join(
652
+ process.env.SystemRoot ?? 'C:\\Windows',
653
+ 'System32',
654
+ 'WindowsPowerShell',
655
+ 'v1.0',
656
+ 'powershell.exe',
657
+ )
658
+ if (!existsSync(powershell)) {
659
+ resolve(false)
660
+ return
661
+ }
662
+ let child
609
663
  try {
610
- return ctx.get('webApp', false)?.getAdvertisedUrl?.()
664
+ child = spawn(powershell, ['-NoProfile', '-NonInteractive', '-Command', TRAY_CLICK_SCRIPT], {
665
+ windowsHide: true,
666
+ stdio: 'ignore',
667
+ })
611
668
  } catch {
612
- return undefined
669
+ resolve(false)
670
+ return
671
+ }
672
+ let settled = false
673
+ const finish = (value) => {
674
+ if (settled) return
675
+ settled = true
676
+ resolve(value)
677
+ }
678
+ const timer = setTimeout(() => {
679
+ try {
680
+ child.kill()
681
+ } catch {
682
+ /* already gone */
683
+ }
684
+ finish(false)
685
+ }, 6000)
686
+ timer.unref?.()
687
+ child.on('error', () => {
688
+ clearTimeout(timer)
689
+ finish(false)
690
+ })
691
+ child.on('close', (code) => {
692
+ clearTimeout(timer)
693
+ finish(code === 0)
694
+ })
695
+ })
696
+ }
697
+ /**
698
+ * Reopen the desktop window, doing the cheap thing first.
699
+ *
700
+ * The pet can handle the common cases itself: it already enumerates the shell's windows, and
701
+ * IsWindowVisible / IsIconic / WM_SYSCOMMAND are plain Win32 calls that the sandbox's low
702
+ * integrity does not restrict. So it is told to try first, and the tray click — which costs
703
+ * a PowerShell process and about 650 ms, because only a medium-integrity process can read
704
+ * the taskbar's automation tree — happens only when the pet reports that the window is hidden
705
+ * and out of its reach.
706
+ *
707
+ * In steady state, where the window is already visible or merely minimised, nothing here
708
+ * runs at all.
709
+ */
710
+ async function openHarness(ctx, runtime) {
711
+ if (runtime.pet === null) {
712
+ // No pet to try through: go straight to the shell's own route.
713
+ if (await clickTrayIcon()) return
714
+ const url = (() => {
715
+ try {
716
+ return ctx.get('webApp', false)?.getAdvertisedUrl?.()
717
+ } catch {
718
+ return undefined
719
+ }
720
+ })()
721
+ if (url === undefined || url === null || url === '') return
722
+ try {
723
+ const child = spawn('cmd.exe', ['/c', 'start', '', url], { windowsHide: true, detached: true, stdio: 'ignore' })
724
+ child.unref()
725
+ } catch (error) {
726
+ ctx.logger?.warn?.(`[dsh-lite-pet] cannot open the Harness: ${String(error?.message ?? error)}`)
613
727
  }
614
- })()
615
- const registered = desktopProtocolRegistered()
616
- // The pet does the window work: it is a native process, so it can call
617
- // ShellExecuteEx on dsh://open (measured to be the only invocation that reaches the
618
- // running desktop instance — `cmd start` and `rundll32 url.dll` both failed here),
619
- // and it can restore a minimised window through WM_SYSCOMMAND/SC_RESTORE.
620
- // When the protocol is not registered, fall back to opening the web GUI.
621
- if (registered) {
622
- sendPet(runtime, { t: 'raise' })
623
728
  return
624
729
  }
625
- if (url === undefined || url === null || url === '') return
626
- try {
627
- const child = spawn('cmd.exe', ['/c', 'start', '', url], { windowsHide: true, detached: true, stdio: 'ignore' })
628
- child.unref()
629
- } catch (error) {
630
- ctx.logger?.warn?.(`[dsh-lite-pet] cannot open the Harness: ${String(error?.message ?? error)}`)
631
- }
730
+ sendPet(runtime, { t: 'raise' })
731
+ }
732
+
733
+ /**
734
+ * Click the tray icon on the pet's behalf.
735
+ *
736
+ * The pet sends this after its own attempt showed that the window is hidden, so no
737
+ * visibility check is repeated here: the point of the message is that the cheap routes are
738
+ * exhausted.
739
+ */
740
+ async function reopenHidden(ctx, runtime) {
741
+ if (await clickTrayIcon()) return
742
+ // The shell route is the last thing left; it does not reopen a hidden window on this
743
+ // machine, but it is harmless and does work in builds whose handler runs synchronously.
744
+ sendPet(runtime, { t: 'raise' })
632
745
  }
633
746
 
634
747
  // ——— browser routes ————————————————————————————————————————————————————
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-pet",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "轻量系统级桌宠:原生进程逐像素透明渲染,工作正文/等待确认/完成提示与余额查询联动,点击穿透",
5
5
  "type": "module",
6
6
  "main": "index.js",
Binary file