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 +164 -51
- package/package.json +1 -1
- package/runtime/pet/DshLitePet.exe +0 -0
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
|
-
|
|
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 &&
|
|
527
|
+
if (state.waiting > 0 && state.touched > waitingTouched) {
|
|
504
528
|
waitingAgent = agentId
|
|
505
|
-
|
|
529
|
+
waitingTouched = state.touched
|
|
506
530
|
}
|
|
507
|
-
if (state.running &&
|
|
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
|
-
// ———
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
608
|
-
|
|
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
|
-
|
|
664
|
+
child = spawn(powershell, ['-NoProfile', '-NonInteractive', '-Command', TRAY_CLICK_SCRIPT], {
|
|
665
|
+
windowsHide: true,
|
|
666
|
+
stdio: 'ignore',
|
|
667
|
+
})
|
|
611
668
|
} catch {
|
|
612
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
Binary file
|