dsh-pve-vm 0.1.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
3
3
  All notable changes to this plugin are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/).
5
5
 
6
+ ## [0.2.0] — 2026-10-13
7
+
8
+ Watch the agent work: a live VM screen in the right sidebar of the web UI.
9
+
10
+ ### Added
11
+
12
+ * **The “VM screen” tab** — a right-sidebar panel that streams the frames the agent sees. The frames
13
+ come from the agent's own RFB session, so no second VNC session appears and the `qm vncproxy`
14
+ password is never reset; captures and tools share one session queue. Watch-only by design: the
15
+ panel injects nothing into the guest.
16
+ * **`viewAutoOpen`** (off by default) — reveal the tab by itself when the agent touches a console.
17
+ * **`viewFollowAgent`** (on by default) — the panel follows the VM the agent works with; a VM chip
18
+ pins it by hand. Watching never counts as agent work: frame requests suppress the activity mark
19
+ (`withConsole(..., { mark: false })`), so the panel cannot chase itself.
20
+ * **`viewFps`** (2) and **`viewScale`** (2) — frame pace and resolution divisor, both changeable on
21
+ the fly inside the panel.
22
+ * **Host routes** `/pve-vm/console/state`, `/frame` and `/release` on the harness web server,
23
+ registered only when the composition has a `webServer`. They carry a loopback-only fence: Host must
24
+ be loopback, a present Origin must match Host, `sec-fetch-site: cross-site` is refused and POST must
25
+ be `application/json`. A harness bound to a LAN therefore keeps the panel dark.
26
+ * `RfbClient.waitForUpdate({ idleOk: true })` — silence at a message boundary means "the screen did
27
+ not change" instead of an error, while a timeout inside a message stays fatal.
28
+
29
+ ### Fixed
30
+
31
+ * **`inputChannel` and `sendkeyDelayMs` never reached the tools.** Both keys were missing from
32
+ `CONFIG_KEYS`, so `snapshot()` dropped them and every run silently used the built-in `auto`/80 ms
33
+ defaults. The form accepted the values, the code ignored them.
34
+
6
35
  ## [0.1.1] — 2026-10-13
7
36
 
8
37
  Linux guest support. Everything below was found by running the plugin against a real RedOS 8
package/README.md CHANGED
@@ -30,6 +30,9 @@ agent ──► plugin ──► ssh ──► node: qm vncproxy ──► unix
30
30
  * **Clipboard** — `console_paste` writes UTF-8 text into the guest clipboard over RFB and presses
31
31
  Ctrl+V; `console_clipboard` reads the guest clipboard back, so text can leave the VM without OCR.
32
32
  * **A settings card** in the web UI (English and Russian labels), backed by the profile patch layer.
33
+ * **A live screen panel** — the “VM screen” tab in the right sidebar shows the frames the agent
34
+ sees, taken from the very same VNC session, while the agent works. It follows the agent's VM
35
+ automatically or stays on the VM you pick by hand. Watching only: the panel never clicks or types.
33
36
  * **Zero runtime dependencies** — RFB, DES authentication, keysym tables and PNG encoding are
34
37
  implemented in this package on top of `node:crypto` and `node:zlib`. There is nothing to install
35
38
  on the host, and nothing to install on the node either.
@@ -95,6 +98,10 @@ layer, and the same values can be edited there by hand:
95
98
  sessionIdleMs: 300000
96
99
  guestTimeoutMs: 60000
97
100
  guestTypeCommand: '' # script for typing non-ASCII text into a Linux guest
101
+ viewAutoOpen: false # open the "VM screen" tab by itself when the agent touches a console
102
+ viewFollowAgent: true # the panel follows the VM the agent works with
103
+ viewFps: 2 # frame pace in the panel, 1–4
104
+ viewScale: 2 # frame resolution divisor: 1 native, 2 half, 4 quarter
98
105
  ```
99
106
 
100
107
  **First run checklist**
@@ -137,6 +144,33 @@ Every action tool accepts a common tail: `observe` (return a frame after the act
137
144
  | `vm_type_text` | `vmid`, `text`, `settleMs?`, `observe?` | Type text through the best available channel: ASCII → console, non-ASCII → the user session on Windows / `xdotool`–`wtype` on Linux. |
138
145
  | `vm_status` | `vmid?` | Is the VM allowed, does the agent answer, which OS, are there open console sessions. |
139
146
 
147
+ ## Watch the VM screen live
148
+
149
+ The **VM screen** tab in the right sidebar shows what the agent sees while it works. Add it from the
150
+ sidebar guide, or let `viewAutoOpen` reveal it the moment the agent touches a console.
151
+
152
+ * The frames are taken from the agent's own RFB session — **no second VNC session appears** and the
153
+ `qm vncproxy` password is never reset under the agent's feet. Captures and the agent's tools share
154
+ one session queue, so they never tear the stream for each other.
155
+ * `viewFollowAgent` keeps the panel on the VM the agent is working with; clicking a VM chip pins the
156
+ panel to that VM instead. `viewFps` (1–4) and `viewScale` (1 / 2 / 4) control the pace and the
157
+ resolution, and both can be changed on the fly inside the panel.
158
+ * The panel is **watch-only**: no clicks, no typing, nothing is injected into the guest. A blank
159
+ frame is called out explicitly — the guest blanked its display or is locked.
160
+ * Frames are served by the plugin's own routes (`/pve-vm/console/frame`, `/state`, `/release`) on the
161
+ harness web server, behind a loopback-only fence: a request is answered only when the `Host` is
162
+ loopback (`localhost`, `127.0.0.0/8`, `[::1]`), a present `Origin` matches `Host`, and
163
+ `sec-fetch-site: cross-site` is refused. If you bind the harness to your LAN, the panel stays dark
164
+ by design — the same fence the harness applies to `/api`.
165
+ * **The fence is loopback, not the harness token.** These routes live outside `/api`, so the
166
+ harness URL token does not cover them (the browser-skill overview works the same way). Any
167
+ process on the same machine can therefore pull frames or call `/release`; that is the accepted
168
+ price of loading frames without a token on a single-user box. Serve VMs you would not mind a
169
+ local user seeing, and harden with a harness-issued token if that is not acceptable.
170
+ * The console session stays open while the panel streams (the idle reaper does not take it); the
171
+ panel's “disconnect console” button closes it immediately. Streaming pauses by itself when the tab
172
+ is hidden.
173
+
140
174
  ## How it works
141
175
 
142
176
  * **Transport.** A VM has no TCP VNC port: QEMU listens on the unix socket
package/client.js CHANGED
@@ -77,6 +77,43 @@ window.__ModuleLoader__.load({
77
77
  'field.guestTypeCommand': 'Ввод не-ASCII в Linux-госте',
78
78
  'field.guestTypeCommandHint':
79
79
  'Скрипт ввода не-ASCII текста в Linux-госте; получает base64 текста первым аргументом.',
80
+ 'field.viewAutoOpen': 'Открывать панель экрана сама',
81
+ 'field.viewAutoOpenHint':
82
+ 'Когда агент прикасается к консоли ВМ, вкладка «Экран ВМ» раскрывается сама в правом сайдбаре.',
83
+ 'field.viewFollowAgent': 'Панель следует за агентом',
84
+ 'field.viewFollowAgentHint':
85
+ 'Показывать ВМ, с которой работает агент. Выключено — панель держит ВМ, выбранную вручную.',
86
+ 'field.viewFps': 'Кадров в секунду в панели',
87
+ 'field.viewFpsHint':
88
+ '1–4. Два кадра в секунду — рабочий темп: движение агента видно, узел не перегружен.',
89
+ 'field.viewScale': 'Делитель разрешения кадра',
90
+ 'field.viewScaleHint':
91
+ '1 — нативное, 2 — половина (по умолчанию), 4 — четверть. Меньше пикселей — быстрее кадр.',
92
+ 'tab.title': 'Экран ВМ',
93
+ 'tab.guide': 'Смотреть экран виртуальной машины, пока агент с ней работает.',
94
+ 'view.follow': 'следовать за агентом',
95
+ 'view.pause': 'пауза',
96
+ 'view.play': 'смотреть',
97
+ 'view.snap': 'кадр сейчас',
98
+ 'view.release': 'отключить консоль',
99
+ 'view.released': 'сессия консоли закрыта',
100
+ 'view.releaseFailed': 'закрыть сессию не удалось',
101
+ 'view.fps': 'кадров/с',
102
+ 'view.scale': 'разрешение',
103
+ 'view.scaleNative': 'нативное',
104
+ 'view.scaleHalf': 'половина',
105
+ 'view.scaleQuarter': 'четверть',
106
+ 'view.empty': 'В allowVmids нет ни одной ВМ — добавьте её в настройках плагина.',
107
+ 'view.consoleOff': 'Инструменты консоли выключены настройкой enableConsole.',
108
+ 'view.waiting': 'Ждём первый кадр…',
109
+ 'view.paused': 'поток на паузе',
110
+ 'view.blank': 'Кадр почти одноцветный: гость погасил дисплей или экран заблокирован.',
111
+ 'view.closed': 'сессия консоли закрылась — следующий кадр откроет её заново',
112
+ 'view.status': 'ВМ {vmid} · экран {resolution} · кадр {capture} · курсор {pointer}',
113
+ 'view.bytes': '{kb} КБ',
114
+ 'view.hostError': 'хост ответил ошибкой: {message}',
115
+ 'view.noHost': 'нет связи с хостом: {message}',
116
+ 'view.agentVm': 'агент работал с ВМ {vmid}',
80
117
  },
81
118
  en: {
82
119
  section: 'Proxmox VE',
@@ -120,6 +157,43 @@ window.__ModuleLoader__.load({
120
157
  'field.guestTypeCommand': 'Non-ASCII typing in a Linux guest',
121
158
  'field.guestTypeCommandHint':
122
159
  'Script that types non-ASCII text inside a Linux guest; receives base64 text as $1.',
160
+ 'field.viewAutoOpen': 'Open the screen panel by itself',
161
+ 'field.viewAutoOpenHint':
162
+ 'When the agent touches a VM console, the “VM screen” tab opens itself in the right sidebar.',
163
+ 'field.viewFollowAgent': 'Panel follows the agent',
164
+ 'field.viewFollowAgentHint':
165
+ 'Show the VM the agent is working with. Off — the panel keeps the VM you picked by hand.',
166
+ 'field.viewFps': 'Frames per second in the panel',
167
+ 'field.viewFpsHint':
168
+ '1–4. Two frames per second is the working pace: the agent’s moves are visible, the node stays calm.',
169
+ 'field.viewScale': 'Frame resolution divisor',
170
+ 'field.viewScaleHint':
171
+ '1 — native, 2 — half (default), 4 — quarter. Fewer pixels — faster frame.',
172
+ 'tab.title': 'VM screen',
173
+ 'tab.guide': 'Watch the virtual machine screen while the agent works with it.',
174
+ 'view.follow': 'follow the agent',
175
+ 'view.pause': 'pause',
176
+ 'view.play': 'watch',
177
+ 'view.snap': 'frame now',
178
+ 'view.release': 'disconnect console',
179
+ 'view.released': 'console session closed',
180
+ 'view.releaseFailed': 'could not close the session',
181
+ 'view.fps': 'frames/s',
182
+ 'view.scale': 'resolution',
183
+ 'view.scaleNative': 'native',
184
+ 'view.scaleHalf': 'half',
185
+ 'view.scaleQuarter': 'quarter',
186
+ 'view.empty': 'No VM is allowed yet — add one in the plugin settings.',
187
+ 'view.consoleOff': 'Console tools are disabled by the enableConsole setting.',
188
+ 'view.waiting': 'Waiting for the first frame…',
189
+ 'view.paused': 'stream paused',
190
+ 'view.blank': 'The frame is almost one colour: the guest blanked the display or the screen is locked.',
191
+ 'view.closed': 'the console session closed — the next frame opens it again',
192
+ 'view.status': 'VM {vmid} · screen {resolution} · frame {capture} · pointer {pointer}',
193
+ 'view.bytes': '{kb} KB',
194
+ 'view.hostError': 'the host answered with an error: {message}',
195
+ 'view.noHost': 'no connection to the host: {message}',
196
+ 'view.agentVm': 'the agent worked with VM {vmid}',
123
197
  },
124
198
  };
125
199
 
@@ -147,6 +221,10 @@ window.__ModuleLoader__.load({
147
221
  { name: 'sessionIdleMs', kind: 'number' },
148
222
  { name: 'guestTimeoutMs', kind: 'number' },
149
223
  { name: 'guestTypeCommand', kind: 'textarea', hintKey: 'field.guestTypeCommandHint' },
224
+ { name: 'viewAutoOpen', kind: 'boolean', hintKey: 'field.viewAutoOpenHint' },
225
+ { name: 'viewFollowAgent', kind: 'boolean', hintKey: 'field.viewFollowAgentHint' },
226
+ { name: 'viewFps', kind: 'number', hintKey: 'field.viewFpsHint' },
227
+ { name: 'viewScale', kind: 'number', hintKey: 'field.viewScaleHint' },
150
228
  ];
151
229
 
152
230
  const STYLE_ID = 'dsh-pve-vm-styles';
@@ -167,6 +245,19 @@ window.__ModuleLoader__.load({
167
245
  .pve-vm-select, .pve-vm-textarea { width: 100%; font: inherit; font-size: 12.5px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); }
168
246
  .pve-vm-textarea { min-height: 96px; resize: vertical; }
169
247
  .pve-vm-select:focus, .pve-vm-textarea:focus { outline: none; border-color: var(--dsw-alias-brand-primary); }
248
+ .pve-vm-view { display: flex; flex-direction: column; gap: 10px; padding: 10px 10px 16px; color: var(--dsw-alias-label-primary); font-size: 12.5px; }
249
+ .pve-vm-view-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
250
+ .pve-vm-chip { font: inherit; font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); cursor: pointer; }
251
+ .pve-vm-chip[data-active="1"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
252
+ .pve-vm-stage { display: flex; align-items: center; justify-content: center; min-height: 110px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
253
+ .pve-vm-stage img { display: block; width: 100%; height: auto; }
254
+ .pve-vm-stage-empty { padding: 22px 14px; text-align: center; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.5; }
255
+ .pve-vm-line { color: var(--dsw-alias-label-secondary); font-size: 11.5px; line-height: 1.45; }
256
+ .pve-vm-warn { border: 1px solid var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); border-radius: 8px; padding: 6px 9px; font-size: 11.5px; line-height: 1.45; }
257
+ .pve-vm-error { border: 1px solid var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); border-radius: 8px; padding: 6px 9px; font-size: 11.5px; line-height: 1.45; }
258
+ .pve-vm-ok { color: var(--dsw-alias-state-success-primary); font-size: 11.5px; }
259
+ .pve-vm-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
260
+ .pve-vm-tools .pve-vm-select { width: auto; }
170
261
  `;
171
262
 
172
263
  /** Add the card styles once. */
@@ -364,6 +455,398 @@ window.__ModuleLoader__.load({
364
455
  });
365
456
  }
366
457
 
458
+ /** The tab type id: it is also the key in the right sidebar slots. */
459
+ const TAB_ID = 'dsh-pve-vm/console';
460
+ /** The tab type discriminator: tabs are opened by this name. */
461
+ const TAB_KIND = 'pve-vm-console';
462
+ /** Base path of the panel routes on the host (lib/routes.js). */
463
+ const VIEW_BASE = '/pve-vm/console';
464
+ /** How often the panel asks the node for its state, ms. */
465
+ const STATE_POLL_MS = 2500;
466
+ /** How often the plugin checks "the agent touched a console" for auto-open, ms. */
467
+ const AUTO_OPEN_POLL_MS = 3000;
468
+
469
+ /**
470
+ * Panel settings from the same form as `settings.section`.
471
+ * The bounds are the host's `resolveSettings` bounds: a value typed by a human
472
+ * may be anything, while the frame has to stay predictable.
473
+ * @param {object} snapshot the configForms snapshot.
474
+ * @returns {{autoOpen:boolean, followAgent:boolean, fps:number, scale:number}}
475
+ */
476
+ function viewDefaults(snapshot) {
477
+ const values = snapshot?.value ?? {};
478
+ const clamp = (value, min, max, fallback) => {
479
+ const parsed = Math.round(Number(value));
480
+ if (!Number.isFinite(parsed)) return fallback;
481
+ return Math.max(min, Math.min(max, parsed));
482
+ };
483
+ return {
484
+ autoOpen: values.viewAutoOpen === true,
485
+ followAgent: values.viewFollowAgent !== false,
486
+ fps: clamp(values.viewFps, 1, 4, 2),
487
+ scale: clamp(values.viewScale, 1, 4, 2),
488
+ };
489
+ }
490
+
491
+ /**
492
+ * What the panel shows right now. Both the tab body and its chip need it: the
493
+ * chip is drawn by a separate slot and knows nothing about component state.
494
+ */
495
+ const viewStore = {
496
+ vmid: null,
497
+ listeners: new Set(),
498
+ set(vmid) {
499
+ if (this.vmid === vmid) return;
500
+ this.vmid = vmid;
501
+ for (const listener of [...this.listeners]) listener();
502
+ },
503
+ subscribe(listener) {
504
+ this.listeners.add(listener);
505
+ return () => this.listeners.delete(listener);
506
+ },
507
+ };
508
+
509
+ /** Frame metadata from the route response headers. */
510
+ function readFrameMeta(headers) {
511
+ return {
512
+ resolution: headers.get('x-pve-vm-resolution'),
513
+ capture: headers.get('x-pve-vm-capture'),
514
+ scale: Number(headers.get('x-pve-vm-scale') ?? 1),
515
+ pointer: headers.get('x-pve-vm-pointer'),
516
+ blank: headers.get('x-pve-vm-blank') === '1',
517
+ session: headers.get('x-pve-vm-session'),
518
+ };
519
+ }
520
+
521
+ /** The tab chip: "VM screen · 121". */
522
+ function ConsoleTitle(props) {
523
+ const t = props.t;
524
+ const [vmid, setVmid] = React.useState(viewStore.vmid);
525
+ React.useEffect(() => viewStore.subscribe(() => setVmid(viewStore.vmid)), []);
526
+ return jsx('span', { children: vmid === null ? t('tab.title') : `${t('tab.title')} · ${vmid}` });
527
+ }
528
+
529
+ /**
530
+ * The observation panel: a live VM screen in the right sidebar.
531
+ *
532
+ * Frames come from the plugin's routes (`/pve-vm/console/frame`) — the very
533
+ * frames the agent sees, taken from its RFB session. The stream runs while the
534
+ * tab is visible and not paused; the next frame is requested only after the
535
+ * previous one arrived, so the session queue is never flooded.
536
+ * @param {{t:Function, ctx:object, form:object, useTabInfo?:Function}} props the slot props.
537
+ */
538
+ function ConsolePane(props) {
539
+ const t = props.t;
540
+ const form = props.form;
541
+ const tab = typeof props.useTabInfo === 'function' ? props.useTabInfo() : null;
542
+ const visible = tab?.tab?.visible !== false;
543
+
544
+ const [settings, setSettings] = React.useState(() => viewDefaults(form.getSnapshot()));
545
+ const [state, setState] = React.useState(null);
546
+ const [stateError, setStateError] = React.useState(null);
547
+ const [follow, setFollow] = React.useState(null);
548
+ const [manualVmid, setManualVmid] = React.useState(null);
549
+ const [streaming, setStreaming] = React.useState(true);
550
+ const [fps, setFps] = React.useState(null);
551
+ const [scale, setScale] = React.useState(null);
552
+ const [frame, setFrame] = React.useState(null);
553
+ const [frameError, setFrameError] = React.useState(null);
554
+ const [note, setNote] = React.useState(null);
555
+ const [tick, setTick] = React.useState(0);
556
+ const frameUrl = React.useRef(null);
557
+ const [, setLocaleTick] = React.useState(0);
558
+
559
+ React.useEffect(() => form.subscribe(() => setSettings(viewDefaults(form.getSnapshot()))), [form]);
560
+ // A language change must repaint the panel labels, not just its title.
561
+ React.useEffect(() => {
562
+ const unsubscribe = props.ctx.locale.subscribe?.(() => setLocaleTick((value) => value + 1));
563
+ return typeof unsubscribe === 'function' ? unsubscribe : undefined;
564
+ }, [props.ctx]);
565
+
566
+ // Node state: the allowed VMs, live sessions and whom to follow.
567
+ React.useEffect(() => {
568
+ if (!visible) return undefined;
569
+ let cancelled = false;
570
+ let timer;
571
+ const poll = async () => {
572
+ try {
573
+ const response = await fetch(`${VIEW_BASE}/state`, {
574
+ headers: { accept: 'application/json' },
575
+ cache: 'no-store',
576
+ });
577
+ const body = await response.json().catch(() => ({}));
578
+ if (cancelled) return;
579
+ if (response.ok) {
580
+ setState(body);
581
+ setStateError(null);
582
+ } else {
583
+ setStateError(body.error ?? `HTTP ${response.status}`);
584
+ }
585
+ } catch (error) {
586
+ if (!cancelled) setStateError(String(error?.message ?? error));
587
+ } finally {
588
+ if (!cancelled) timer = setTimeout(poll, STATE_POLL_MS);
589
+ }
590
+ };
591
+ poll();
592
+ return () => {
593
+ cancelled = true;
594
+ clearTimeout(timer);
595
+ };
596
+ }, [visible]);
597
+
598
+ const followAgent = follow ?? settings.followAgent;
599
+ const fpsEff = fps ?? settings.fps;
600
+ const scaleEff = scale ?? settings.scale;
601
+ const allowed = Array.isArray(state?.allowed) ? state.allowed : [];
602
+ const lastVmid = state?.last?.vmid ?? null;
603
+ const preferred = followAgent ? lastVmid : manualVmid;
604
+ const vmid = allowed.includes(preferred)
605
+ ? preferred
606
+ : allowed.includes(manualVmid)
607
+ ? manualVmid
608
+ : (allowed[0] ?? null);
609
+
610
+ React.useEffect(() => viewStore.set(vmid), [vmid]);
611
+
612
+ // The frame stream: the next request only after the previous answer.
613
+ React.useEffect(() => {
614
+ if (!visible || !streaming || vmid === null) return undefined;
615
+ let cancelled = false;
616
+ let timer;
617
+ const step = async () => {
618
+ const started = Date.now();
619
+ try {
620
+ const response = await fetch(`${VIEW_BASE}/frame?vmid=${vmid}&scale=${scaleEff}&cursor=1`, {
621
+ cache: 'no-store',
622
+ });
623
+ if (!response.ok) {
624
+ const body = await response.json().catch(() => ({}));
625
+ throw new Error(body.error ?? `HTTP ${response.status}`);
626
+ }
627
+ const blob = await response.blob();
628
+ if (cancelled) return;
629
+ const next = URL.createObjectURL(blob);
630
+ if (frameUrl.current !== null) URL.revokeObjectURL(frameUrl.current);
631
+ frameUrl.current = next;
632
+ setFrame({ url: next, meta: readFrameMeta(response.headers), bytes: blob.size, at: Date.now() });
633
+ setFrameError(null);
634
+ } catch (error) {
635
+ if (!cancelled) setFrameError(String(error?.message ?? error));
636
+ } finally {
637
+ if (!cancelled) {
638
+ const wait = Math.max(80, Math.round(1000 / fpsEff) - (Date.now() - started));
639
+ timer = setTimeout(step, wait);
640
+ }
641
+ }
642
+ };
643
+ step();
644
+ return () => {
645
+ cancelled = true;
646
+ clearTimeout(timer);
647
+ };
648
+ }, [visible, streaming, vmid, scaleEff, fpsEff, tick]);
649
+
650
+ // The last frame lives in a blob URL: it must be released when the panel unloads.
651
+ React.useEffect(
652
+ () => () => {
653
+ if (frameUrl.current !== null) URL.revokeObjectURL(frameUrl.current);
654
+ },
655
+ [],
656
+ );
657
+
658
+ /** Close the VM console session on the node. */
659
+ const release = async () => {
660
+ try {
661
+ const response = await fetch(`${VIEW_BASE}/release`, {
662
+ method: 'POST',
663
+ headers: { 'content-type': 'application/json' },
664
+ body: JSON.stringify({ vmid }),
665
+ });
666
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
667
+ setNote(t('view.released'));
668
+ } catch {
669
+ setNote(t('view.releaseFailed'));
670
+ }
671
+ setTimeout(() => setNote(null), 2500);
672
+ };
673
+
674
+ const chips = allowed.map((id) =>
675
+ jsx(
676
+ 'button',
677
+ {
678
+ type: 'button',
679
+ className: 'pve-vm-chip',
680
+ 'data-active': id === vmid ? '1' : '0',
681
+ onClick: () => {
682
+ setManualVmid(id);
683
+ setFollow(false);
684
+ },
685
+ children: String(id),
686
+ },
687
+ id,
688
+ ),
689
+ );
690
+
691
+ const banners = [];
692
+ if (stateError !== null) {
693
+ banners.push(jsx('div', { className: 'pve-vm-error', children: t('view.noHost', { message: stateError }) }, 'state'));
694
+ }
695
+ if (state?.consoleEnabled === false) {
696
+ banners.push(jsx('div', { className: 'pve-vm-warn', children: t('view.consoleOff') }, 'console'));
697
+ }
698
+ if (allowed.length === 0 && state !== null) {
699
+ banners.push(jsx('div', { className: 'pve-vm-warn', children: t('view.empty') }, 'empty'));
700
+ }
701
+ if (frameError !== null) {
702
+ banners.push(jsx('div', { className: 'pve-vm-error', children: t('view.hostError', { message: frameError }) }, 'frame'));
703
+ }
704
+ if (frame?.meta?.blank === true) {
705
+ banners.push(jsx('div', { className: 'pve-vm-warn', children: t('view.blank') }, 'blank'));
706
+ }
707
+ if (frame?.meta?.session === 'closed') {
708
+ banners.push(jsx('div', { className: 'pve-vm-warn', children: t('view.closed') }, 'closed'));
709
+ }
710
+ if (!streaming) {
711
+ banners.push(jsx('div', { className: 'pve-vm-line', children: t('view.paused') }, 'paused'));
712
+ }
713
+
714
+ const status =
715
+ frame === null
716
+ ? null
717
+ : `${t('view.status', {
718
+ vmid: String(vmid ?? '—'),
719
+ resolution: frame.meta.resolution ?? '—',
720
+ capture: frame.meta.capture ?? '—',
721
+ pointer: frame.meta.pointer ?? '—',
722
+ })} · ${t('view.bytes', { kb: String(Math.max(1, Math.round(frame.bytes / 1024))) })}`;
723
+
724
+ return jsxs('div', {
725
+ className: 'pve-vm-view',
726
+ children: [
727
+ jsxs('div', {
728
+ className: 'pve-vm-view-head',
729
+ children: [
730
+ ...chips,
731
+ lastVmid !== null
732
+ ? jsx('span', { className: 'pve-vm-line', children: t('view.agentVm', { vmid: String(lastVmid) }) })
733
+ : null,
734
+ ],
735
+ }),
736
+ jsx('div', {
737
+ className: 'pve-vm-stage',
738
+ children:
739
+ frame === null
740
+ ? jsx('div', {
741
+ className: 'pve-vm-stage-empty',
742
+ children: allowed.length === 0 ? t('view.empty') : t('view.waiting'),
743
+ })
744
+ : jsx('img', { src: frame.url, alt: '' }),
745
+ }),
746
+ status === null ? null : jsx('div', { className: 'pve-vm-line', children: status }),
747
+ ...banners,
748
+ jsxs('div', {
749
+ className: 'pve-vm-tools',
750
+ children: [
751
+ jsx(Button, {
752
+ variant: 'outline',
753
+ size: 'sm',
754
+ children: streaming ? t('view.pause') : t('view.play'),
755
+ onClick: () => setStreaming((value) => !value),
756
+ }),
757
+ jsx(Button, {
758
+ variant: 'ghost',
759
+ size: 'sm',
760
+ disabled: vmid === null,
761
+ children: t('view.snap'),
762
+ onClick: () => setTick((value) => value + 1),
763
+ }),
764
+ jsx(Button, {
765
+ variant: 'ghost',
766
+ size: 'sm',
767
+ disabled: vmid === null,
768
+ children: t('view.release'),
769
+ onClick: release,
770
+ }),
771
+ jsx(Checkbox, {
772
+ checked: followAgent,
773
+ label: t('view.follow'),
774
+ onChange: (next) => setFollow(next),
775
+ }),
776
+ jsx('select', {
777
+ className: 'pve-vm-select',
778
+ value: String(fpsEff),
779
+ title: t('view.fps'),
780
+ onChange: (event) => setFps(Number(event.target.value)),
781
+ children: [1, 2, 3, 4].map((value) =>
782
+ jsx('option', { value: String(value), children: `${value} ${t('view.fps')}` }, value),
783
+ ),
784
+ }),
785
+ jsx('select', {
786
+ className: 'pve-vm-select',
787
+ value: String(scaleEff),
788
+ title: t('view.scale'),
789
+ onChange: (event) => setScale(Number(event.target.value)),
790
+ children: [
791
+ { value: 1, key: 'view.scaleNative' },
792
+ { value: 2, key: 'view.scaleHalf' },
793
+ { value: 4, key: 'view.scaleQuarter' },
794
+ ].map((item) =>
795
+ jsx('option', { value: String(item.value), children: `${t('view.scale')}: ${t(item.key)}` }, item.value),
796
+ ),
797
+ }),
798
+ note === null ? null : jsx('span', { className: 'pve-vm-ok', children: note }),
799
+ ],
800
+ }),
801
+ ],
802
+ });
803
+ }
804
+
805
+ /**
806
+ * Tab auto-open: when the agent touches a VM console the panel reveals itself —
807
+ * if the owner asked for it with the `viewAutoOpen` setting.
808
+ *
809
+ * The first state read only remembers the mark: otherwise every page reload
810
+ * would open the tab because of the agent's older work.
811
+ * @param {object} ctx the client context.
812
+ * @returns {() => void} stops watching.
813
+ */
814
+ function armAutoOpen(ctx) {
815
+ const form = ctx.configForms.get(NAMESPACE);
816
+ let primed = false;
817
+ let seenAt = 0;
818
+ let stopped = false;
819
+ const tick = async () => {
820
+ if (stopped) return;
821
+ const values = form.getSnapshot()?.value ?? {};
822
+ if (values.viewAutoOpen !== true) return;
823
+ try {
824
+ const response = await fetch(`${VIEW_BASE}/state`, { headers: { accept: 'application/json' }, cache: 'no-store' });
825
+ if (!response.ok) return;
826
+ const state = await response.json();
827
+ const at = Number(state?.last?.at ?? 0);
828
+ if (!primed) {
829
+ primed = true;
830
+ seenAt = at;
831
+ return;
832
+ }
833
+ if (at <= seenAt) return;
834
+ seenAt = at;
835
+ const sessionId = ctx.sidebarRight.mounted.getSnapshot();
836
+ if (sessionId === undefined) return;
837
+ ctx.sidebarRight.openTabIn(sessionId, TAB_KIND);
838
+ } catch {
839
+ // Network and response parsing are no reason to shout in the console: the
840
+ // panel shows the error anyway once it is opened.
841
+ }
842
+ };
843
+ const timer = setInterval(tick, AUTO_OPEN_POLL_MS);
844
+ return () => {
845
+ stopped = true;
846
+ clearInterval(timer);
847
+ };
848
+ }
849
+
367
850
  /**
368
851
  * Attach the card to the settings section.
369
852
  * @param {object} ctx the client context.
@@ -393,6 +876,58 @@ window.__ModuleLoader__.load({
393
876
  PveVmSection,
394
877
  ),
395
878
  );
879
+ // The observation panel is a second, independent contribution. Not every
880
+ // composition has a right sidebar, so the services are declared through
881
+ // ctx.inject: without them the settings card still works (see §4.4.1 of the
882
+ // project AGENTS.md — never turn a service read into an undeclared, optional
883
+ // dependency).
884
+ // каждой композиции, поэтому сервисы объявлены через ctx.inject: без них
885
+ // карточка настроек всё равно работает (см. §4.4.1 проектного AGENTS.md —
886
+ // не превращать чтение сервиса в необязательную, но необъявленную зависимость).
887
+ ctx.inject(['sidebarRight', 'sidebarRightTabs'], (scoped) => {
888
+ const disposers = [];
889
+ const own = (dispose) => {
890
+ if (typeof dispose === 'function') disposers.push(dispose);
891
+ };
892
+
893
+ own(
894
+ scoped.sidebarRightTabs.register({
895
+ id: TAB_ID,
896
+ kind: TAB_KIND,
897
+ title: () => translate()('tab.title'),
898
+ guide: [
899
+ {
900
+ id: 'console',
901
+ order: 55,
902
+ title: () => translate()('tab.title'),
903
+ description: () => translate()('tab.guide'),
904
+ },
905
+ ],
906
+ }),
907
+ );
908
+
909
+ const injectPane = () => ({ ctx, form: ctx.configForms.get(NAMESPACE) });
910
+ own(
911
+ scoped.slots.inject('sidebar.right.pane.tab', () =>
912
+ scoped.slots.register(
913
+ { name: 'sidebar.right.pane.tab', key: TAB_ID, locale: LOCALE_NS, inject: injectPane },
914
+ ConsolePane,
915
+ ),
916
+ ),
917
+ );
918
+ own(
919
+ scoped.slots.inject('sidebar.right.pane.tab.title', () =>
920
+ scoped.slots.register({ name: 'sidebar.right.pane.tab.title', key: TAB_ID, locale: LOCALE_NS }, ConsoleTitle),
921
+ ),
922
+ );
923
+
924
+ // Автооткрытие — отдельный слой: он живёт и когда панель закрыта.
925
+ const stopAutoOpen = armAutoOpen(ctx);
926
+ return () => {
927
+ stopAutoOpen();
928
+ for (const dispose of disposers.reverse()) dispose();
929
+ };
930
+ });
396
931
  }
397
932
 
398
933
  exports.apply = apply;
package/index.js CHANGED
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import { Config, resolveSettings, snapshot } from './lib/config.js';
13
13
  import { VmController } from './lib/session.js';
14
+ import { ConsoleView } from './lib/view.js';
15
+ import { registerViewRoutes } from './lib/routes.js';
14
16
  import { consoleTools } from './tools/console.js';
15
17
  import { guestTools } from './tools/guest.js';
16
18
 
@@ -35,6 +37,13 @@ export function apply(ctx, config) {
35
37
  const deps = { controller, settings, attachments: ctx.attachments };
36
38
  for (const tool of [...consoleTools(deps), ...guestTools(deps)]) ctx.tools.register(tool);
37
39
 
40
+ // Observation panel: the client half pulls frames from these routes. A
41
+ // composition may have no web server at all (headless profile) — then there is
42
+ // simply no panel while the tools keep working, so the dependency is declared
43
+ // through ctx.inject instead of the plugin's mandatory `inject` list.
44
+ const view = new ConsoleView({ controller, settings });
45
+ ctx.inject(['webServer'], (scoped) => registerViewRoutes(scoped.webServer, { view }));
46
+
38
47
  // The computer-use slot is per composition: if another provider has already taken it,
39
48
  // the plugin tools still work, so the error is only recorded.
40
49
  ctx.inject(['computerUse'], (scoped) => {
package/lib/config.js CHANGED
@@ -56,10 +56,16 @@ export const CONFIG_KEYS = [
56
56
  'allowVmids',
57
57
  'enableConsole',
58
58
  'enableGuest',
59
+ 'inputChannel',
60
+ 'sendkeyDelayMs',
59
61
  'consoleTimeoutMs',
60
62
  'sessionIdleMs',
61
63
  'guestTimeoutMs',
62
64
  'guestTypeCommand',
65
+ 'viewAutoOpen',
66
+ 'viewFollowAgent',
67
+ 'viewFps',
68
+ 'viewScale',
63
69
  ];
64
70
 
65
71
  /** Settings schema: the id of the entry in `cordis.patch.yml` must match `pve-vm`. */
@@ -141,6 +147,36 @@ export const Config = z.object({
141
147
  'Script for typing non-ASCII text in a Linux guest: takes the base64 text as its first argument ($1).',
142
148
  )
143
149
  .volatile(),
150
+ viewAutoOpen: z
151
+ .boolean()
152
+ .default(false)
153
+ .description(
154
+ 'Open the "VM screen" tab in the right sidebar by itself when the agent touches a VM console.',
155
+ )
156
+ .volatile(),
157
+ viewFollowAgent: z
158
+ .boolean()
159
+ .default(true)
160
+ .description('The panel follows the VM the agent is working with by default.')
161
+ .volatile(),
162
+ viewFps: z
163
+ .number()
164
+ .step(1)
165
+ .min(1)
166
+ .max(4)
167
+ .default(2)
168
+ .description('How many frames per second the panel asks the node for (1–4).')
169
+ .volatile(),
170
+ viewScale: z
171
+ .number()
172
+ .step(1)
173
+ .min(1)
174
+ .max(4)
175
+ .default(2)
176
+ .description(
177
+ 'Frame resolution divisor in the panel: 1 — native, 2 — half (default), 4 — quarter.',
178
+ )
179
+ .volatile(),
144
180
  });
145
181
 
146
182
  /**
@@ -169,7 +205,7 @@ function expandHome(path) {
169
205
  /**
170
206
  * Reduce the settings slice to a shape convenient for the tools.
171
207
  * @param {Record<string, unknown>} section the result of {@link snapshot}.
172
- * @returns {{ssh:object,allowVmids:number[],enableConsole:boolean,enableGuest:boolean,consoleTimeoutMs:number,sessionIdleMs:number,guestTimeoutMs:number,guestTypeCommand:string}}
208
+ * @returns {{ssh:object,allowVmids:number[],enableConsole:boolean,enableGuest:boolean,consoleTimeoutMs:number,sessionIdleMs:number,guestTimeoutMs:number,guestTypeCommand:string,view:{autoOpen:boolean,followAgent:boolean,fps:number,scale:number}}}
173
209
  */
174
210
  export function resolveSettings(section) {
175
211
  const allowVmids = Array.isArray(section.allowVmids)
@@ -193,5 +229,25 @@ export function resolveSettings(section) {
193
229
  sessionIdleMs: Number(section.sessionIdleMs ?? 300000),
194
230
  guestTimeoutMs: Number(section.guestTimeoutMs ?? 60000),
195
231
  guestTypeCommand: typeof section.guestTypeCommand === 'string' ? section.guestTypeCommand : DEFAULT_GUEST_TYPE_COMMAND,
232
+ view: {
233
+ autoOpen: section.viewAutoOpen === true,
234
+ followAgent: section.viewFollowAgent !== false,
235
+ fps: clampNumber(section.viewFps, 1, 4, 2),
236
+ scale: clampNumber(section.viewScale, 1, 4, 2),
237
+ },
196
238
  };
197
239
  }
240
+
241
+ /**
242
+ * An integer inside bounds (for the panel fields: fps and the resolution divisor).
243
+ * @param {unknown} value live settings value.
244
+ * @param {number} min lower bound.
245
+ * @param {number} max upper bound.
246
+ * @param {number} fallback default value.
247
+ * @returns {number} a usable number.
248
+ */
249
+ function clampNumber(value, min, max, fallback) {
250
+ const parsed = Math.round(Number(value));
251
+ if (!Number.isFinite(parsed)) return fallback;
252
+ return Math.max(min, Math.min(max, parsed));
253
+ }
package/lib/png.js CHANGED
@@ -106,6 +106,25 @@ export function downscaleBox(image, factor) {
106
106
  return { width, height, data: out, scale: 1 / step };
107
107
  }
108
108
 
109
+ /**
110
+ * An almost single-colour frame: most often a blanked display or a black lock screen.
111
+ * The check samples coarsely instead of scanning a million pixels. It lives here, not in
112
+ * the tools, because the observation panel needs the very same verdict.
113
+ * @param {{width:number,height:number,data:Uint8Array}} image RGBA frame.
114
+ * @returns {boolean} true when the frame is practically one colour.
115
+ */
116
+ export function looksBlank(image) {
117
+ const step = Math.max(4, Math.floor((image.width * image.height) / 4096)) * 4;
118
+ let min = 255;
119
+ let max = 0;
120
+ for (let i = 0; i < image.data.length; i += step) {
121
+ const value = image.data[i];
122
+ if (value < min) min = value;
123
+ if (value > max) max = value;
124
+ }
125
+ return max - min <= 6;
126
+ }
127
+
109
128
  /**
110
129
  * Rectangle cropping; the coordinates are clipped to the frame.
111
130
  * @param {{width:number,height:number,data:Uint8Array}} image the source RGBA frame.
package/lib/rfb.js CHANGED
@@ -350,19 +350,38 @@ export class RfbClient extends EventEmitter {
350
350
 
351
351
  /**
352
352
  * Read messages until the next FramebufferUpdate.
353
- * @param {{timeoutMs?:number}} [options] wait timeout.
354
- * @returns {Promise<{rectangles:number, bytes:number, resized:boolean, cursor:boolean, bell:boolean}>}
353
+ *
354
+ * `idleOk` is what the observation panel needs: it asks for an incremental frame
355
+ * often, and "nothing changed" is a normal answer from QEMU (it simply stays
356
+ * silent). Silence at a message boundary is safe — the current framebuffer is
357
+ * returned. A timeout in the middle of a message means half a frame was lost,
358
+ * which is a real error and the session has to be recreated.
359
+ * @param {{timeoutMs?:number, idleOk?:boolean}} [options] wait timeout and tolerance for silence.
360
+ * @returns {Promise<{rectangles:number, bytes:number, resized:boolean, cursor:boolean, bell:boolean, idle?:boolean}>}
355
361
  */
356
362
  async waitForUpdate(options = {}) {
357
363
  const deadline = Date.now() + (options.timeoutMs ?? this.timeoutMs);
364
+ const idleOk = options.idleOk === true;
358
365
  let rectangles = 0;
359
366
  let cursorUpdated = false;
360
367
  let bell = false;
361
368
  let resized = false;
362
369
  for (;;) {
363
370
  const left = deadline - Date.now();
364
- if (left <= 0) throw new RfbError('timeout waiting for an RFB frame', 'TIMEOUT');
365
- const type = (await this.reader.exact(1, left))[0];
371
+ if (left <= 0) {
372
+ if (idleOk) return { rectangles: 0, bytes: 0, resized: false, cursor: false, bell: false, idle: true };
373
+ throw new RfbError('timeout waiting for an RFB frame', 'TIMEOUT');
374
+ }
375
+ let first;
376
+ try {
377
+ first = await this.reader.exact(1, left);
378
+ } catch (error) {
379
+ if (idleOk && error?.code === 'TIMEOUT') {
380
+ return { rectangles: 0, bytes: 0, resized: false, cursor: false, bell: false, idle: true };
381
+ }
382
+ throw error;
383
+ }
384
+ const type = first[0];
366
385
  if (type === 0) {
367
386
  const header = await this.reader.exact(3, left);
368
387
  const count = header.readUInt16BE(1);
package/lib/routes.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * HTTP routes of the observation panel: `/pve-vm/console/*`.
3
+ *
4
+ * Why HTTP and not a harness RPC. The panel is an ordinary page of the same origin,
5
+ * and frames are easy to pull with `fetch`; the browser-skill overview is built the
6
+ * same way (`/bsk-observation/*`). The routes register on the `webServer` service; a
7
+ * composition without a web server (a headless profile) simply never registers them,
8
+ * while the agent tools keep working as usual.
9
+ *
10
+ * Security. A VM screen is sensitive: passwords, chats, a working desktop. So these
11
+ * routes carry their own fence: we answer only when Host is a loopback authority
12
+ * (localhost / 127.0.0.0/8 / [::1]), a present Origin matches Host, and
13
+ * `sec-fetch-site: cross-site` is refused outright; POST must be `application/json`,
14
+ * so a cross-site simple request never reaches the handler. DSH itself applies the
15
+ * same order of checks to `/api` (`@deepseek-ai/dsh-client-connection`), and so does
16
+ * browser-skill; it is repeated here so that the published plugin depends on no
17
+ * internal package.
18
+ */
19
+ import { VmError } from './session.js';
20
+
21
+ /** Base path of the panel routes. */
22
+ export const VIEW_BASE = '/pve-vm/console';
23
+
24
+ /** Error codes to HTTP statuses. */
25
+ const STATUS_BY_CODE = {
26
+ BAD_VMID: 400,
27
+ NOT_ALLOWED: 403,
28
+ CHANNEL_DISABLED: 403,
29
+ CONSOLE_CLOSED: 409,
30
+ CONSOLE_FAILED: 502,
31
+ SSH_FAILED: 502,
32
+ EOF: 502,
33
+ TIMEOUT: 504,
34
+ };
35
+
36
+ /** Answer with JSON. */
37
+ function sendJson(res, status, body) {
38
+ const payload = Buffer.from(JSON.stringify(body), 'utf8');
39
+ res.writeHead(status, {
40
+ 'content-type': 'application/json; charset=utf-8',
41
+ 'content-length': String(payload.length),
42
+ 'cache-control': 'no-store',
43
+ });
44
+ res.end(payload);
45
+ }
46
+
47
+ /** Turn a panel request failure into a response. */
48
+ function fail(res, error) {
49
+ if (res.headersSent) {
50
+ res.end();
51
+ return;
52
+ }
53
+ const code = error instanceof VmError ? error.code : undefined;
54
+ const status = STATUS_BY_CODE[code] ?? 500;
55
+ sendJson(res, status, { error: error?.message ?? String(error), ...(code ? { code } : {}) });
56
+ }
57
+
58
+ /**
59
+ * The fence's verdict: the reason for refusal, or undefined.
60
+ * @param {import('node:http').IncomingMessage} req the request.
61
+ * @returns {string|undefined} the reason for refusal.
62
+ */
63
+ function fenceViolation(req) {
64
+ const host = req.headers.host ?? '';
65
+ const hostname = /^\[.*\](?::\d+)?$/.test(host) ? host.slice(1, host.indexOf(']')) : host.split(':')[0];
66
+ const loopback =
67
+ hostname === 'localhost' ||
68
+ hostname.endsWith('.localhost') ||
69
+ hostname === '::1' ||
70
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
71
+ if (!loopback) return 'host is not a loopback authority';
72
+ const origin = req.headers.origin;
73
+ if (origin !== undefined && origin !== 'null') {
74
+ let originHost;
75
+ try {
76
+ originHost = new URL(origin).host;
77
+ } catch {
78
+ return 'unparseable Origin header';
79
+ }
80
+ if (originHost !== host) return 'Origin does not match Host';
81
+ }
82
+ if (req.headers['sec-fetch-site'] === 'cross-site') return 'sec-fetch-site: cross-site';
83
+ if (req.method === 'POST') {
84
+ const contentType = req.headers['content-type'] ?? '';
85
+ if (!/^\s*application\/json\s*(;|$)/.test(contentType)) return 'POST requires an application/json body';
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /** Run the fence; true means the request was already refused and answered. */
91
+ function fenceRejected(req, res) {
92
+ const violation = fenceViolation(req);
93
+ if (violation === undefined) return false;
94
+ sendJson(res, 403, { error: `forbidden: ${violation}` });
95
+ return true;
96
+ }
97
+
98
+ /** Read the JSON body of a small request. */
99
+ async function readJsonBody(req) {
100
+ const chunks = [];
101
+ let size = 0;
102
+ for await (const chunk of req) {
103
+ size += chunk.length;
104
+ if (size > 64 * 1024) throw new VmError('request body is too large', 'BAD_BODY');
105
+ chunks.push(chunk);
106
+ }
107
+ if (size === 0) return {};
108
+ try {
109
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
110
+ } catch {
111
+ throw new VmError('request body is not JSON', 'BAD_BODY');
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Register the panel routes on the web server.
117
+ * @param {object} webServer the `webServer` service.
118
+ * @param {{view: object}} deps the observation panel.
119
+ * @returns {() => void} disposer removing the routes.
120
+ */
121
+ export function registerViewRoutes(webServer, deps) {
122
+ const view = deps.view;
123
+ const disposers = [
124
+ webServer.register({
125
+ kind: 'exact',
126
+ path: `${VIEW_BASE}/state`,
127
+ handler: (req, res) => {
128
+ if (req.method !== 'GET') {
129
+ sendJson(res, 405, { error: 'method not allowed' });
130
+ return;
131
+ }
132
+ if (fenceRejected(req, res)) return;
133
+ try {
134
+ sendJson(res, 200, view.state());
135
+ } catch (error) {
136
+ fail(res, error);
137
+ }
138
+ },
139
+ }),
140
+ webServer.register({
141
+ kind: 'exact',
142
+ path: `${VIEW_BASE}/frame`,
143
+ handler: async (req, res) => {
144
+ if (req.method !== 'GET') {
145
+ sendJson(res, 405, { error: 'method not allowed' });
146
+ return;
147
+ }
148
+ if (fenceRejected(req, res)) return;
149
+ const url = new URL(req.url ?? '/', 'http://localhost');
150
+ const rawScale = url.searchParams.get('scale');
151
+ try {
152
+ const { png, meta } = await view.frame(Number(url.searchParams.get('vmid')), {
153
+ scale: rawScale === null ? undefined : Number(rawScale),
154
+ cursor: url.searchParams.get('cursor') !== '0',
155
+ });
156
+ res.writeHead(200, {
157
+ 'content-type': 'image/png',
158
+ 'content-length': String(png.length),
159
+ 'cache-control': 'no-store, max-age=0',
160
+ 'x-pve-vm-vmid': String(meta.vmid),
161
+ 'x-pve-vm-resolution': `${meta.width}x${meta.height}`,
162
+ 'x-pve-vm-capture': `${meta.captureWidth}x${meta.captureHeight}`,
163
+ 'x-pve-vm-scale': String(meta.scale),
164
+ 'x-pve-vm-pointer': `${meta.pointerX},${meta.pointerY}`,
165
+ 'x-pve-vm-cursor': meta.cursorDrawn ? '1' : '0',
166
+ 'x-pve-vm-blank': meta.blank ? '1' : '0',
167
+ 'x-pve-vm-session': meta.sessionAlive ? 'alive' : 'closed',
168
+ });
169
+ res.end(png);
170
+ } catch (error) {
171
+ fail(res, error);
172
+ }
173
+ },
174
+ }),
175
+ webServer.register({
176
+ kind: 'exact',
177
+ path: `${VIEW_BASE}/release`,
178
+ handler: async (req, res) => {
179
+ if (req.method !== 'POST') {
180
+ sendJson(res, 405, { error: 'method not allowed' });
181
+ return;
182
+ }
183
+ if (fenceRejected(req, res)) return;
184
+ try {
185
+ const body = await readJsonBody(req);
186
+ const closed = view.release(Number(body.vmid));
187
+ sendJson(res, 200, { closed });
188
+ } catch (error) {
189
+ fail(res, error);
190
+ }
191
+ },
192
+ }),
193
+ ];
194
+ return () => {
195
+ for (const dispose of disposers.reverse()) dispose();
196
+ };
197
+ }
package/lib/session.js CHANGED
@@ -55,10 +55,20 @@ export class VmController {
55
55
  this.sessions = new Map();
56
56
  /** @type {Map<number, string>} */
57
57
  this.osCache = new Map();
58
+ /** The last VM whose console the agent touched: the panel auto-opens on it. */
59
+ this.lastConsole = null;
58
60
  this.reaper = setInterval(() => this.#reapIdle(), 30000);
59
61
  this.reaper.unref?.();
60
62
  }
61
63
 
64
+ /**
65
+ * The last console use: the observation panel shows this VM.
66
+ * @returns {{vmid:number, at:number}|null} vmid and time (ms), or null.
67
+ */
68
+ lastConsoleUse() {
69
+ return this.lastConsole;
70
+ }
71
+
62
72
  /** Validate the VM id against the allowlist and the channel switches. */
63
73
  assertAllowed(vmid, channel) {
64
74
  const settings = this.deps.settings();
@@ -275,12 +285,19 @@ export class VmController {
275
285
  * Run an operation with exclusive access to the VM console.
276
286
  * @param {number} vmid VM id.
277
287
  * @param {(client: RfbClient, session: object) => Promise<T>} operation the action.
278
- * @param {{signal?: AbortSignal}} [options]
288
+ * @param {{signal?: AbortSignal, mark?:boolean}} [options] cancellation and the "the agent worked with this VM" mark.
279
289
  * @returns {Promise<T>}
280
290
  * @template T
281
291
  */
282
292
  async withConsole(vmid, operation, options = {}) {
283
293
  const id = this.assertAllowed(vmid, 'console');
294
+ // A mark for the observation panel: it follows the VM the agent works with.
295
+ // The panel watches frames through this very method, so it has to suppress the
296
+ // mark (`mark: false`) — otherwise watching declares itself agent work and the
297
+ // "follow the agent" mode jumps between VMs (seen on a live panel 2026-10-13:
298
+ // right after a restart `last` named the VM the owner was watching, while no
299
+ // tool had touched it).
300
+ if (options.mark !== false) this.lastConsole = { vmid: id, at: Date.now() };
284
301
  const session = await this.#consoleSession(id, options.signal);
285
302
  const run = async () => {
286
303
  if (!session.alive) {
package/lib/view.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The live VM screen for the observation panel.
3
+ *
4
+ * Why a separate module. The agent's tools capture a frame on demand and hand it
5
+ * back as an attachment; a panel needs something else: often, cheaply, and without
6
+ * attachments — "what is on screen right now". The frame comes from **the same**
7
+ * RFB session the tools use (`VmController.withConsole`), therefore:
8
+ *
9
+ * • no second VNC session to the VM appears, and the `qm vncproxy` password is
10
+ * not reset under the agent's feet;
11
+ * • there is still exactly one session queue — the panel and the agent never
12
+ * tear the stream for each other;
13
+ * • a session the panel holds open is not reaped by the idle reaper (every
14
+ * capture refreshes `lastUsed`).
15
+ *
16
+ * Frames are requested incrementally: when the screen did not change QEMU stays
17
+ * silent and the panel gets the current framebuffer from the client's memory (see
18
+ * `idleOk` in `RfbClient.waitForUpdate`).
19
+ */
20
+ import { encodePng, downscaleBox, looksBlank } from './png.js';
21
+
22
+ /** Lower bound on waiting for QEMU: below it we simply cannot read the answer. */
23
+ const MIN_WAIT_MS = 120;
24
+ /**
25
+ * Upper bound on waiting. This is exactly how long the panel holds the session
26
+ * queue when the screen does not change (QEMU stays silent on an incremental
27
+ * request) — and therefore how long the agent waits for its own operation.
28
+ * 250 ms is enough: QEMU hands over changed regions right after the request, and
29
+ * anything that happens later is picked up by the next frame.
30
+ */
31
+ const MAX_WAIT_MS = 250;
32
+ /** zlib level for panel frames: speed over size, the channel is loopback. */
33
+ const PNG_LEVEL = 3;
34
+ /** Allowed resolution divisors (1 — native). */
35
+ const DIVISORS = [1, 2, 3, 4];
36
+
37
+ /**
38
+ * How long to wait for a frame at the panel's pace.
39
+ * @param {number} fps frames per second from the settings.
40
+ * @returns {number} wait timeout, ms.
41
+ */
42
+ function waitForFps(fps) {
43
+ const interval = 1000 / Math.max(1, fps);
44
+ return Math.max(MIN_WAIT_MS, Math.min(MAX_WAIT_MS, Math.round(interval - 50)));
45
+ }
46
+
47
+ /**
48
+ * One VM's screen as the agent sees it.
49
+ */
50
+ export class ConsoleView {
51
+ /**
52
+ * @param {{controller: object, settings: () => object}} deps the VM controller and live settings.
53
+ */
54
+ constructor(deps) {
55
+ this.deps = deps;
56
+ /** @type {Map<string, Promise<object>>} captures already running: two clients do not queue twice. */
57
+ this.pending = new Map();
58
+ }
59
+
60
+ /** State for the panel: what is allowed, what is alive, whom to follow. */
61
+ state() {
62
+ const settings = this.deps.settings();
63
+ const last = this.deps.controller.lastConsoleUse();
64
+ return {
65
+ allowed: settings.allowVmids,
66
+ consoleEnabled: settings.enableConsole,
67
+ sessions: this.deps.controller.listSessions(),
68
+ last: last ? { vmid: last.vmid, at: last.at } : null,
69
+ view: settings.view,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * A frame of the VM screen as PNG.
75
+ * @param {number} vmid VM id.
76
+ * @param {{scale?:number, cursor?:boolean}} [options] resolution divisor and whether to draw the cursor.
77
+ * @returns {Promise<{png:Buffer, meta:object}>} the PNG and its metadata.
78
+ */
79
+ frame(vmid, options = {}) {
80
+ const settings = this.deps.settings();
81
+ const id = this.deps.controller.assertAllowed(vmid, 'console');
82
+ const divisor = DIVISORS.includes(Number(options.scale)) ? Number(options.scale) : settings.view.scale;
83
+ const drawCursor = options.cursor !== false;
84
+ const key = `${id}:${divisor}:${drawCursor ? 1 : 0}`;
85
+ const running = this.pending.get(key);
86
+ if (running !== undefined) return running;
87
+ const task = this.#capture(id, divisor, drawCursor, waitForFps(settings.view.fps)).finally(() => {
88
+ this.pending.delete(key);
89
+ });
90
+ this.pending.set(key, task);
91
+ return task;
92
+ }
93
+
94
+ /**
95
+ * Close the VM console session (the panel's "disconnect" button).
96
+ * @param {number} vmid VM id.
97
+ * @returns {boolean} true when a session existed and was closed.
98
+ */
99
+ release(vmid) {
100
+ return this.deps.controller.close(vmid);
101
+ }
102
+
103
+ /**
104
+ * Capture a frame with exclusive access to the console.
105
+ * @param {number} vmid VM id.
106
+ * @param {number} divisor resolution divisor.
107
+ * @param {boolean} drawCursor whether to draw the cursor.
108
+ * @param {number} waitMs how long to wait for the incremental answer.
109
+ * @returns {Promise<{png:Buffer, meta:object}>}
110
+ */
111
+ async #capture(vmid, divisor, drawCursor, waitMs) {
112
+ // `mark: false` — watching is not agent work: otherwise the panel would move
113
+ // the "whom to follow" mark on every frame.
114
+ return this.deps.controller.withConsole(
115
+ vmid,
116
+ async (client, session) => {
117
+ // The first frame of a session is requested in full: a fresh client has no
118
+ // "changed" regions yet, and an incremental request could stay silent.
119
+ client.requestUpdate({ incremental: client.bytesIn > 0 });
120
+ await client.waitForUpdate({ timeoutMs: waitMs, idleOk: true });
121
+ const frame = client.snapshot({ drawCursor });
122
+ const scaled = divisor > 1 ? downscaleBox(frame, divisor) : { ...frame, scale: 1 };
123
+ const png = encodePng(scaled, { level: PNG_LEVEL });
124
+ return {
125
+ png,
126
+ meta: {
127
+ vmid,
128
+ width: frame.width,
129
+ height: frame.height,
130
+ captureWidth: scaled.width,
131
+ captureHeight: scaled.height,
132
+ scale: divisor,
133
+ pointerX: client.pointer.x,
134
+ pointerY: client.pointer.y,
135
+ cursorDrawn: Boolean(client.cursor) && drawCursor,
136
+ blank: looksBlank(frame),
137
+ bytes: png.length,
138
+ sessionAlive: session.alive !== false,
139
+ },
140
+ };
141
+ },
142
+ { mark: false },
143
+ );
144
+ }
145
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pve-vm",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Proxmox VE VM control for DeepSeek Harness: see the screen and click in a VM console over VNC, plus commands, files and typed text through the QEMU guest agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,6 +58,7 @@
58
58
  "inject": [
59
59
  "@deepseek-ai/dsh-client-ui-settings",
60
60
  "@deepseek-ai/dsh-client-ui-slots",
61
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
61
62
  "@deepseek-ai/dsh-client-locale"
62
63
  ]
63
64
  }
@@ -74,7 +75,7 @@
74
75
  "@deepseek-ai/schemastery": "^3.18.4"
75
76
  },
76
77
  "scripts": {
77
- "check": "node --check index.js && node --check client.js && node --check lib/config.js && node --check lib/guest.js && node --check lib/hmp.js && node --check lib/keys.js && node --check lib/png.js && node --check lib/rfb.js && node --check lib/session.js && node --check lib/ssh.js && node --check tools/console.js && node --check tools/guest.js",
78
+ "check": "node --check index.js && node --check client.js && node --check lib/config.js && node --check lib/guest.js && node --check lib/hmp.js && node --check lib/keys.js && node --check lib/png.js && node --check lib/rfb.js && node --check lib/routes.js && node --check lib/session.js && node --check lib/ssh.js && node --check lib/view.js && node --check tools/console.js && node --check tools/guest.js",
78
79
  "test": "node tools/check-plugin.mjs && node tools/check-client.mjs",
79
80
  "prepublishOnly": "npm run check",
80
81
  "probe": "node tools/probe-rfb.mjs"