@bitefoundry/retrieviewer 0.0.1 → 0.0.2

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/INSTALLING.md CHANGED
@@ -1,6 +1,16 @@
1
- # Enable Retrieviewer in an agent TUI
1
+ # Install Retrieviewer
2
2
 
3
- 0.0.1 includes an installer CLI and a native OpenCode TUI adapter. It does **not** claim
3
+ Windows desktop setup (Node20+ and the Windows .NET Framework4 compiler required):
4
+
5
+ 1. `npm install -g @bitefoundry/retrieviewer`
6
+ 2. `retrieviewer desktop` — select the app window in the picker.
7
+
8
+ No agent configuration is needed for this separate transparent companion.
9
+ See [desktop controls, compatibility and removal](WINDOWS-COMPANION.md).
10
+
11
+ ## Optional native OpenCode integration
12
+
13
+ 0.0.2 also includes an installer CLI and a native OpenCode TUI adapter. It does **not** claim
4
14
  that every MCP client can display animated UI. Native OpenCode rendering uses its
5
15
  official TUI plugin slot, not another terminal, a wrapper, or a modified binary.
6
16
  Other agent IDs are discoverable but refused until an adapter is implemented.
@@ -12,7 +22,7 @@ agent are required. Source code is MIT; bundled puppy artwork is CC BY-SA 4.0.
12
22
  Keep the included ATTRIBUTION.md and asset license when redistributing artwork.
13
23
 
14
24
  ```sh
15
- npm install -g @bitefoundry/retrieviewer@0.0.1
25
+ npm install -g @bitefoundry/retrieviewer
16
26
  retrieviewer enable
17
27
  ```
18
28
 
@@ -31,9 +41,7 @@ version is **not automatically certified**. Existing project configuration can
31
41
  override global settings; doctor checks registration, not arbitrary host precedence.
32
42
  Do not put API keys in Retrieviewer settings. No credentials are needed by the pet.
33
43
 
34
- After a future public release, the install command will instead be
35
- `npm install -g @bitefoundry/retrieviewer@0.0.1`. Do not run that registry command
36
- before publication. The package also exports `./tui` for npm-aware OpenCode plugin
44
+ The package also exports `./tui` for npm-aware OpenCode plugin
37
45
  loaders; package-name discovery has not yet been live-tested against npm.
38
46
  The verified installer continues using its local directory URL, not that new path.
39
47
 
@@ -54,6 +62,17 @@ the new package. No automatic global install is done by the package itself.
54
62
 
55
63
  ## Commands
56
64
 
65
+ For the separate Windows overlay (included since0.0.2),
66
+ see [Windows companion](WINDOWS-COMPANION.md). It does not register a Codex plugin
67
+ or change the way an agent is launched:
68
+
69
+ ```powershell
70
+ retrieviewer desktop
71
+ retrieviewer desktop-status --json
72
+ retrieviewer desktop-stop
73
+ retrieviewer desktop-remove --yes
74
+ ```
75
+
57
76
  ```sh
58
77
  retrieviewer agents --json
59
78
  retrieviewer status --agent opencode
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  An autonomous, fluffy Braille retriever that lives in a pane of your terminal UI.
4
4
  Give it a ball or a treat; otherwise it wanders and plays on its own. By BiteFoundry.
5
5
 
6
- **Version 0.0.1.** Source code is MIT; puppy artwork is CC BY-SA 4.0, including
6
+ **Version 0.0.2.** Source code is MIT; puppy artwork is CC BY-SA 4.0, including
7
7
  commercial use and adaptations under its attribution/ShareAlike conditions.
8
8
  See [licensing](./LICENSE), [artwork terms](./ASSETS-LICENSE.md),
9
9
  [credits](./ATTRIBUTION.md) and [brand notice](./TRADEMARKS.md).
@@ -11,11 +11,36 @@ The complete package is not MIT-only.
11
11
 
12
12
  ## Install
13
13
 
14
- Node.js >=20 and a terminal font supporting Unicode Braille are required.
14
+ Windows10/11 x64, Node.js >=20, and the Windows .NET Framework4 compiler are required
15
+ for the desktop companion. The helper builds automatically on first launch.
16
+
17
+ 1. Install:
18
+
19
+ ```powershell
20
+ npm install -g @bitefoundry/retrieviewer
21
+ ```
22
+
23
+ 2. Run and choose the app window you want your puppy to live on:
24
+
25
+ ```powershell
26
+ retrieviewer desktop
27
+ ```
28
+
29
+ That's it. Use your app normally. Ball/treat controls sit above the selected
30
+ window; the pet lets ordinary clicks through. No Codex patch, wrapper, MCP setup,
31
+ administrator access, or automatic startup registration. It follows a whole window,
32
+ not individual tabs, and hides when you switch away. It is a separate transparent
33
+ Windows window, **not a widget injected into another app**. macOS/Linux desktop
34
+ overlays, exclusive fullscreen apps, and privileged/security surfaces are not certified.
35
+
36
+ Exit from the tray icon or `retrieviewer desktop-stop`. To remove the companion's
37
+ own cache, run `retrieviewer desktop-remove --yes`; then
38
+ `npm uninstall -g @bitefoundry/retrieviewer` removes the package. See
39
+ [desktop support and removal](./WINDOWS-COMPANION.md).
40
+
41
+ ## Optional: native OpenCode pane
15
42
 
16
43
  ```sh
17
- npm install -g @bitefoundry/retrieviewer@0.0.1
18
- retrieviewer enable --agent opencode --dry-run
19
44
  retrieviewer enable --agent opencode --yes
20
45
  opencode
21
46
  ```
package/RELEASING.md CHANGED
@@ -1,6 +1,6 @@
1
- # Retrieviewer 0.0.1 release process
1
+ # Retrieviewer 0.0.2 release process
2
2
 
3
- Target: `@bitefoundry/retrieviewer@0.0.1`, public npm registry, `latest` tag.
3
+ Target: `@bitefoundry/retrieviewer@0.0.2`, public npm registry, `latest` tag.
4
4
  The npm organization `bitefoundry` uses the free public-packages plan; account
5
5
  `shinick` owns the organization.
6
6
 
@@ -12,6 +12,10 @@ artwork draft is superseded. ATTRIBUTION.md retains the photographic references
12
12
  and original license links. ASSET-PROVENANCE.md records the bounded review and
13
13
  remaining limits; this is not comprehensive legal clearance.
14
14
 
15
+ The owner also explicitly authorized publishing0.0.2 with the Windows companion
16
+ and the two-step npm install/desktop launch guide. No new art license or Codex
17
+ modification is part of this release. Do not replace the0.0.1 registry artifact.
18
+
15
19
  `release-policy.json` has four independent approval gates and the CC artwork
16
20
  license identifier. The guard still refuses false/missing approvals, private
17
21
  packages, missing attribution/notices, install scripts and known leaked paths.
@@ -29,7 +33,7 @@ npm publish --dry-run --access public --registry=https://registry.npmjs.org/
29
33
  The package test packs the exact candidate, installs it in a fresh offline
30
34
  consumer, byte-compares all installed files and checks exports, frames,
31
35
  interactions, lifecycle, examples, JSONL and isolated installer settings.
32
- Its report is `dist/package-check.json`; it does not publish or edit live
36
+ Its report is in a fresh `dist/package-check-*/package-check.json`; it does not publish or edit live
33
37
  agent configuration. Keep the final tarball hash and file inventory.
34
38
 
35
39
  The product workspace is not a Git repository. No public source repository,
@@ -37,7 +41,7 @@ Actions pipeline or npm trusted publisher is implied by this npm release.
37
41
  Do not upload this experimental workspace wholesale: original photos, source
38
42
  image sheets, workers, QA receipts, credentials and local settings stay local.
39
43
 
40
- ## First publish
44
+ ## Publish the tested tarball
41
45
 
42
46
  Confirm `npm whoami` is the intended account. Publish only the tested candidate
43
47
  with explicit public access; complete any npm browser/2FA verification through
@@ -58,4 +62,9 @@ discovery alone is not live-host certification. Other agent-native renderers
58
62
  remain unimplemented. MCP alone cannot inject a pet into arbitrary agent UIs.
59
63
  See INSTALLING.md for support and removal.
60
64
 
65
+ The separate Windows transparent companion uses native layered windows plus the
66
+ same pet engine. Test its real installed copy with an isolated state directory and
67
+ owned off-screen diagnostic windows; do not stop the user's live companion merely
68
+ to run QA. Source/native diagnostic evidence is not a cross-platform certification.
69
+
61
70
  Historical research notes remain local and are not current support promises.
@@ -0,0 +1,113 @@
1
+ # Windows companion — 0.0.2
2
+
3
+ This is a separate transparent desktop window, not a Codex plugin or a terminal
4
+ wrapper. Version0.0.2 includes it; version0.0.1 does not. Setup is two steps:
5
+
6
+ 1. `npm install -g @bitefoundry/retrieviewer`
7
+ 2. `retrieviewer desktop` — choose the app window in the picker.
8
+
9
+ Choose a supported ordinary Windows app window (including a terminal containing
10
+ Codex). Then use your app normally, without a
11
+ wrapper, shell alias, patched binary, changed credentials, or agent configuration.
12
+ The helper uses native Windows/.NET Framework facilities and the existing Node
13
+ pet engine. No Electron, Chromium, downloaded helper binary or new npm dependency.
14
+ Windows10/11 x64 with Node20+ and the .NET Framework4 compiler is required. Other
15
+ desktop platforms and exclusive fullscreen/security surfaces are not certified.
16
+ Launch reports
17
+ that the picker is ready, **not** that the user has selected a window or visually
18
+ approved the pet.
19
+
20
+ ## Interaction and window scope
21
+
22
+ - The original Q5 Braille artwork/behavior engine is reused without new sprites.
23
+ - The pet is click-through and non-activating. It does not read keyboard input.
24
+ - A small separate, non-activating control strip offers ball, treat, choose window,
25
+ pause and exit. These buttons consume their own clicks, not terminal typing.
26
+ - Ball/treat use the existing engine interactions, including busy/cooldown rules.
27
+ Ball currently uses its existing preset destination, **not free mouse aiming**.
28
+ - The overlay follows the selected window's client rectangle. It hides and stops
29
+ simulation when another app is foreground, the target is minimized, or paused.
30
+ The control strip remains available while paused in the selected window.
31
+ - It binds a whole top-level window, **not a terminal tab/pane**. Switching tabs
32
+ inside that window does not automatically rebind or identify the running agent.
33
+ - Selection is per launch. There is no process scanning to infer Codex sessions,
34
+ screen scraping/OCR, chat capture, global keyboard hook or input injection.
35
+ The picker locally reads window titles/process names; tracking reads window
36
+ geometry, foreground state, visibility and owning process ID.
37
+ - No automatic startup, background scheduled task, registry entry or MCP is added.
38
+ Exit stops both the painter and engine. If either process closes, its counterpart
39
+ exits too. Losing the target hides the pet; tray selection can attach another.
40
+
41
+ ## Stop, remove, and recover
42
+
43
+ ```powershell
44
+ retrieviewer desktop-status --json
45
+ retrieviewer desktop-stop
46
+ retrieviewer desktop-clean --yes
47
+ ```
48
+
49
+ One-command companion removal (stop, wait for exit, remove only its cache):
50
+
51
+ ```powershell
52
+ retrieviewer desktop-remove --yes
53
+ ```
54
+
55
+ The tray icon also provides window selection, hide/resume and exit. `desktop-stop`
56
+ asks the companion to exit through an authenticated local named pipe; it never
57
+ kills a PID from a stale file. The worker may terminate only its own stuck child
58
+ painter after a graceful timeout. Cache cleanup refuses a live/unreachable worker,
59
+ symlinks/junctions and unrecognized session records. It removes only recognized
60
+ helper cache filenames; unrelated files and agent/pet settings remain untouched.
61
+
62
+ Own files are under `~/.config/retrieviewer/desktop` (or RETRIEVIEWER_HOME/desktop):
63
+ one hash-named compiled helper and a transient local session capability record.
64
+ The token is not printed by status and is never sent over a network. Same-user
65
+ local software is not treated as an adversarial security boundary. No screenshot,
66
+ terminal content or window title is persisted by this companion.
67
+
68
+ For a globally installed copy, stop/remove the companion before
69
+ `npm uninstall -g @bitefoundry/retrieviewer`. Other native OpenCode registrations,
70
+ if any, must first be disconnected with the existing explicit disconnect command.
71
+ This companion does not change or remove a pre-existing Codex `/pets` pack.
72
+
73
+ ## Rendering and bounded work
74
+
75
+ Native pixels are dots decoded from the engine's existing Braille cells, on an
76
+ alpha-transparent bitmap. Every frame starts clear, avoiding previous-frame paw
77
+ trails. Only the nonblank bounding rectangle crosses IPC and is painted; the
78
+ native window does not allocate a full-screen transparent bitmap. A request/ack
79
+ bridge permits only one frame in flight. Viewport revisions discard frames from
80
+ an old size/position; discarded frames are acknowledged to avoid deadlock. Hidden
81
+ time is not replayed when the pet becomes visible. The engine targets30fps, but
82
+ the actual frame rate depends on CPU, window resolution and Windows scheduling.
83
+
84
+ ## Search-first decision and evidence
85
+
86
+ Reused `createPet` and its side-effect-free host API. A small native Win32 painter
87
+ was selected for Windows instead of adding an Electron runtime or a PTY wrapper.
88
+ The former adds another browser runtime; the latter changes how Codex is launched
89
+ and was explicitly rejected. This is not a claim of cross-platform support or a
90
+ benchmarked memory advantage; measurements belong to the local validation.
91
+
92
+ - [Layered windows and mouse passthrough](https://learn.microsoft.com/en-us/windows/win32/winmsg/window-features#layered-windows)
93
+ - [UpdateLayeredWindow](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-updatelayeredwindow)
94
+ - [No-activate extended window style](https://learn.microsoft.com/en-us/windows/win32/winmsg/extended-window-styles)
95
+ - [SetWindowPos without activation](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowpos)
96
+ - [Electron alternative](https://www.electronjs.org/docs/latest/tutorial/custom-window-interactions)
97
+
98
+ `node --test retrieviewer-desktop.test.mjs` covers transport/cropping/backpressure,
99
+ hidden-time resets, CLI isolation, authenticated control transport and conservative
100
+ cleanup. Native validation must additionally test layered window rendering, focus,
101
+ geometry, minimize/restore, controls and lifecycle on an actual Windows desktop.
102
+ Do not equate style checks or a rendered PNG with end-user visual approval.
103
+
104
+ Local validation on2026-10-23:281 automated tests passed, real packaged offline
105
+ consumer passed, and the real native worker painted frames with the actual pet
106
+ engine. The native ball button reached the engine and was accepted; an incorrect
107
+ control token was rejected. Owned off-screen native-window checks passed position
108
+ tracking, minimize/restore, passthrough/no-activate styles and foreground retention.
109
+ The worker and painter both exited. Node RSS was about157MiB in that small-window
110
+ check (not total app memory or a4K performance claim). A Windows GUI startup bug
111
+ was fixed by building a GUI-subsystem helper and not applying SW_HIDE to it; the
112
+ real user picker was verified in Windows' visible main-window list. User visual
113
+ approval after selecting a terminal remains separate.
@@ -0,0 +1,259 @@
1
+ // Native Windows painter for the existing Braille engine. No injection, keyboard
2
+ // hooks, terminal output, screen capture, network, registry or agent config access.
3
+ // Built locally with the Windows .NET Framework compiler; no downloaded binaries.
4
+ using System;
5
+ using System.Collections.Generic;
6
+ using System.Diagnostics;
7
+ using System.Drawing;
8
+ using System.Drawing.Imaging;
9
+ using System.IO;
10
+ using System.Runtime.InteropServices;
11
+ using System.Text;
12
+ using System.Threading;
13
+ using System.Web.Script.Serialization;
14
+ using System.Windows.Forms;
15
+
16
+ static class Native {
17
+ [StructLayout(LayoutKind.Sequential)] internal struct POINT { internal int X,Y; internal POINT(int x,int y){X=x;Y=y;} }
18
+ [StructLayout(LayoutKind.Sequential)] internal struct SIZE { internal int Width,Height; internal SIZE(int w,int h){Width=w;Height=h;} }
19
+ [StructLayout(LayoutKind.Sequential)] internal struct RECT { internal int Left,Top,Right,Bottom; }
20
+ [StructLayout(LayoutKind.Sequential,Pack=1)] internal struct BLEND { internal byte Op,Flags,Alpha,Format; }
21
+ internal delegate bool EnumProc(IntPtr hwnd,IntPtr data);
22
+ [DllImport("user32.dll")] internal static extern bool EnumWindows(EnumProc proc,IntPtr data);
23
+ [DllImport("user32.dll")] internal static extern bool IsWindowVisible(IntPtr hwnd);
24
+ [DllImport("user32.dll")] internal static extern bool IsWindow(IntPtr hwnd);
25
+ [DllImport("user32.dll")] internal static extern bool IsIconic(IntPtr hwnd);
26
+ [DllImport("user32.dll")] internal static extern IntPtr GetForegroundWindow();
27
+ [DllImport("user32.dll",CharSet=CharSet.Unicode)] internal static extern int GetWindowText(IntPtr hwnd,StringBuilder text,int length);
28
+ [DllImport("user32.dll")] internal static extern uint GetWindowThreadProcessId(IntPtr hwnd,out uint pid);
29
+ [DllImport("user32.dll")] internal static extern bool GetClientRect(IntPtr hwnd,out RECT rect);
30
+ [DllImport("user32.dll")] internal static extern bool GetWindowRect(IntPtr hwnd,out RECT rect);
31
+ [DllImport("user32.dll")] internal static extern bool ClientToScreen(IntPtr hwnd,ref POINT point);
32
+ [DllImport("user32.dll")] internal static extern bool SetWindowPos(IntPtr hwnd,IntPtr after,int x,int y,int w,int h,uint flags);
33
+ [DllImport("user32.dll")] internal static extern int GetWindowLong(IntPtr hwnd,int index);
34
+ [DllImport("user32.dll")] internal static extern bool ShowWindow(IntPtr hwnd,int command);
35
+ [DllImport("user32.dll")] internal static extern bool SetProcessDPIAware();
36
+ [DllImport("user32.dll")] internal static extern bool SetProcessDpiAwarenessContext(IntPtr context);
37
+ [DllImport("user32.dll")] internal static extern IntPtr GetDC(IntPtr hwnd);
38
+ [DllImport("user32.dll")] internal static extern int ReleaseDC(IntPtr hwnd,IntPtr dc);
39
+ [DllImport("gdi32.dll")] internal static extern IntPtr CreateCompatibleDC(IntPtr dc);
40
+ [DllImport("gdi32.dll")] internal static extern IntPtr SelectObject(IntPtr dc,IntPtr bitmap);
41
+ [DllImport("gdi32.dll")] internal static extern bool DeleteDC(IntPtr dc);
42
+ [DllImport("gdi32.dll")] internal static extern bool DeleteObject(IntPtr obj);
43
+ [DllImport("user32.dll",SetLastError=true)] internal static extern bool UpdateLayeredWindow(IntPtr hwnd,IntPtr dc,ref POINT dst,ref SIZE size,IntPtr src,ref POINT point,int key,ref BLEND blend,int flags);
44
+ internal static Rectangle ClientBounds(IntPtr hwnd){
45
+ RECT r;POINT p=new POINT(0,0);
46
+ if(!GetClientRect(hwnd,out r)||!ClientToScreen(hwnd,ref p))return Rectangle.Empty;
47
+ return new Rectangle(p.X,p.Y,r.Right-r.Left,r.Bottom-r.Top);
48
+ }
49
+ internal static void Place(IntPtr hwnd,Rectangle rect){
50
+ SetWindowPos(hwnd,new IntPtr(-1),rect.X,rect.Y,rect.Width,rect.Height,0x10|0x40); // TOPMOST, NOACTIVATE, SHOWWINDOW
51
+ }
52
+ }
53
+
54
+ class QuietWindow : Form {
55
+ internal QuietWindow(){FormBorderStyle=FormBorderStyle.None;ShowInTaskbar=false;AutoScaleMode=AutoScaleMode.None;StartPosition=FormStartPosition.Manual;}
56
+ protected override bool ShowWithoutActivation {get{return true;}}
57
+ protected override CreateParams CreateParams {get{CreateParams p=base.CreateParams;p.ExStyle|=0x08000000|0x80;return p;}} // NOACTIVATE, TOOLWINDOW
58
+ protected override void WndProc(ref Message m){if(m.Msg==0x21){m.Result=new IntPtr(3);return;}base.WndProc(ref m);} // MA_NOACTIVATE
59
+ }
60
+
61
+ sealed class PetWindow : QuietWindow {
62
+ protected override CreateParams CreateParams {get{CreateParams p=base.CreateParams;p.ExStyle|=0x80000|0x20;return p;}} // LAYERED + TRANSPARENT
63
+ internal int PaintedDots;
64
+ internal Bitmap PaintFrame(Dictionary<string,object> m,int cell,Rectangle canvas){
65
+ int cols=Program.Int(m,"columns"),rows=Program.Int(m,"rows"),cx=Program.Int(m,"x"),cy=Program.Int(m,"y");
66
+ string text=Program.Str(m,"text");
67
+ if(cols==0&&rows==0&&text.Length==0){Hide();return null;}
68
+ if(cols<1||rows<1||cols*rows>125000||cx<0||cy<0||(cx+cols)*cell>canvas.Width||(cy+rows)*cell*2>canvas.Height)throw new Exception("Out-of-bounds overlay frame");
69
+ string[] lines=text.Split('\n');if(lines.Length!=rows)throw new Exception("Invalid overlay row count");
70
+ Bitmap image=new Bitmap(cols*cell,rows*cell*2,PixelFormat.Format32bppPArgb);
71
+ try{
72
+ using(Graphics g=Graphics.FromImage(image))using(Brush ink=new SolidBrush(Color.FromArgb(245,248,242,232))){
73
+ g.Clear(Color.Transparent);g.SmoothingMode=System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
74
+ int[] dx={0,0,0,1,1,1,0,1},dy={0,1,2,0,1,2,3,3};float step=cell/2f,diameter=Math.Max(1.1f,step*.48f);PaintedDots=0;
75
+ for(int y=0;y<rows;y++){
76
+ if(lines[y].Length!=cols)throw new Exception("Invalid overlay column count");
77
+ for(int x=0;x<cols;x++){
78
+ int mask=lines[y][x]-0x2800;if(mask<0||mask>255)throw new Exception("Not a Braille frame");
79
+ for(int bit=0;bit<8;bit++)if((mask&(1<<bit))!=0){
80
+ float px=x*cell+(dx[bit]+.5f)*step,py=y*cell*2+(dy[bit]+.5f)*step;
81
+ g.FillEllipse(ink,px-diameter/2,py-diameter/2,diameter,diameter);PaintedDots++;
82
+ }
83
+ }
84
+ }
85
+ }
86
+ Rectangle destination=new Rectangle(canvas.X+cx*cell,canvas.Y+cy*cell*2,image.Width,image.Height);
87
+ if(!Visible){Bounds=destination;Show();}
88
+ IntPtr screen=Native.GetDC(IntPtr.Zero),memory=Native.CreateCompatibleDC(screen),bitmap=IntPtr.Zero,old=IntPtr.Zero;
89
+ try{
90
+ bitmap=image.GetHbitmap(Color.FromArgb(0));old=Native.SelectObject(memory,bitmap);
91
+ Native.POINT target=new Native.POINT(canvas.X+cx*cell,canvas.Y+cy*cell*2),source=new Native.POINT(0,0);
92
+ Native.SIZE size=new Native.SIZE(image.Width,image.Height);Native.BLEND blend=new Native.BLEND();blend.Alpha=255;blend.Format=1;
93
+ if(!Native.UpdateLayeredWindow(Handle,screen,ref target,ref size,memory,ref source,0,ref blend,2))throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
94
+ Native.Place(Handle,new Rectangle(target.X,target.Y,size.Width,size.Height));
95
+ }finally{if(old!=IntPtr.Zero)Native.SelectObject(memory,old);if(bitmap!=IntPtr.Zero)Native.DeleteObject(bitmap);Native.DeleteDC(memory);Native.ReleaseDC(IntPtr.Zero,screen);}
96
+ return image;
97
+ }catch{image.Dispose();throw;}
98
+ }
99
+ }
100
+
101
+ sealed class TargetItem {
102
+ internal IntPtr Window;internal uint Process;internal string Title;internal bool Terminal;
103
+ public override string ToString(){return Title;}
104
+ }
105
+
106
+ sealed class Companion : ApplicationContext {
107
+ readonly PetWindow pet=new PetWindow();readonly QuietWindow bar=new QuietWindow();
108
+ readonly NotifyIcon tray=new NotifyIcon();readonly System.Windows.Forms.Timer clock=new System.Windows.Forms.Timer();
109
+ readonly JavaScriptSerializer json=new JavaScriptSerializer();readonly object inputLock=new object();
110
+ readonly Queue<Dictionary<string,object>> messages=new Queue<Dictionary<string,object>>();
111
+ TargetItem target;Form pickerWindow;QuietWindow testTarget;readonly bool testMode;Rectangle canvas;int revision=0,cell=8;bool visible=false,paused=false,loaded=false,quitting=false,picking=false,announced=false;
112
+ Label status=new Label();Mutex mutex;
113
+ internal Companion(Mutex single,bool diagnostic){
114
+ mutex=single;testMode=diagnostic;bar.Text="Retrieviewer controls";bar.Size=new Size(354,40);bar.BackColor=Color.FromArgb(28,30,34);bar.ForeColor=Color.White;
115
+ bar.Font=new Font("Segoe UI",9);bar.Controls.Add(MakeButton("공",8,()=>Interact("ball")));bar.Controls.Add(MakeButton("간식",52,()=>Interact("treat")));
116
+ bar.Controls.Add(MakeButton("창",102,ChooseTarget));bar.Controls.Add(MakeButton("쉼",146,TogglePause));bar.Controls.Add(MakeButton("종료",190,Quit));
117
+ status.AutoSize=false;status.Location=new Point(240,12);status.Size=new Size(110,22);status.Text="준비 중";bar.Controls.Add(status);
118
+ ContextMenuStrip menu=new ContextMenuStrip();menu.Items.Add("다른 창 선택",null,(s,e)=>ChooseTarget());menu.Items.Add("숨기기 / 다시 표시",null,(s,e)=>TogglePause());menu.Items.Add("종료",null,(s,e)=>Quit());
119
+ tray.Icon=SystemIcons.Application;tray.Text="Retrieviewer · 창 선택";tray.ContextMenuStrip=menu;tray.Visible=true;tray.DoubleClick+=(s,e)=>ChooseTarget();
120
+ // Handles are created on the UI thread before the reader can signal EOF.
121
+ IntPtr unused=bar.Handle;unused=pet.Handle;
122
+ Thread reader=new Thread(ReadInput);reader.IsBackground=true;reader.Start();
123
+ clock.Interval=16;clock.Tick+=(s,e)=>Tick();clock.Start();
124
+ if(testMode){
125
+ testTarget=new QuietWindow();testTarget.Bounds=new Rectangle(-12000,-12000,1280,800);testTarget.Show();
126
+ target=new TargetItem{Window=testTarget.Handle,Process=(uint)Process.GetCurrentProcess().Id,Title="owned diagnostic window"};
127
+ }
128
+ }
129
+ Button MakeButton(string title,int x,Action action){
130
+ Button b=new Button();b.Text=title;b.TabStop=false;b.Size=new Size(title=="간식"?46:40,28);b.Location=new Point(x,6);b.FlatStyle=FlatStyle.Flat;b.FlatAppearance.BorderSize=0;b.BackColor=Color.FromArgb(48,51,58);b.ForeColor=Color.White;b.Click+=(s,e)=>action();return b;
131
+ }
132
+ void ReadInput(){
133
+ try{
134
+ string line;while((line=Program.Input.ReadLine())!=null){
135
+ if(line.Length>800000)break;
136
+ Dictionary<string,object> value=json.Deserialize<Dictionary<string,object>>(line);
137
+ lock(inputLock){if(messages.Count>4)break;messages.Enqueue(value);}
138
+ }
139
+ }catch{}
140
+ try{bar.BeginInvoke(new Action(Quit));}catch{}
141
+ }
142
+ void Interact(string kind){if(!loaded||paused)return;Program.Emit(new {type="interact",kind=kind});}
143
+ void TogglePause(){paused=!paused;Program.Emit(new {type="pause",paused=paused});Track(true);}
144
+ void Tick(){
145
+ if(quitting)return;
146
+ try{
147
+ if(!announced){announced=true;if(!testMode)ChooseTarget();Program.Emit(new {type="ready",pickerVisible=pickerWindow!=null&&pickerWindow.Visible});}
148
+ Track(false);
149
+ for(;;){Dictionary<string,object> m;lock(inputLock){if(messages.Count==0)break;m=messages.Dequeue();}
150
+ string type=Program.Str(m,"type");
151
+ if(type=="quit"){Quit();return;}
152
+ if(type=="loaded"){loaded=true;status.Text="함께 노는 중";if(testMode)((Button)bar.Controls[0]).PerformClick();continue;}
153
+ if(type=="interaction"){status.Text=Program.Bool(m,"accepted")?"좋아!":"잠깐 기다려";continue;}
154
+ if(type=="error"){status.Text="엔진 오류";Quit();return;}
155
+ if(type=="frame"){
156
+ // Always acknowledge discarded frames too, otherwise resizing can deadlock the sender.
157
+ bool painted=visible&&!paused&&Program.Int(m,"revision")==revision;
158
+ if(painted){using(Bitmap image=pet.PaintFrame(m,cell,canvas)){} }
159
+ Program.Emit(new {type="ack",painted=painted});
160
+ }
161
+ }
162
+ }catch(Exception error){Program.Emit(new {type="error",message=error.Message});Quit();}
163
+ }
164
+ void Track(bool force){
165
+ Rectangle next=Rectangle.Empty;bool show=false;uint pid;
166
+ if(target!=null&&(!Native.IsWindow(target.Window)||Native.GetWindowThreadProcessId(target.Window,out pid)==0||pid!=target.Process)){target=null;}
167
+ if(target!=null&&!Native.IsIconic(target.Window)&&Native.IsWindowVisible(target.Window)){
168
+ next=Native.ClientBounds(target.Window);IntPtr foreground=Native.GetForegroundWindow();show=!picking&&(testMode||foreground==target.Window||foreground==bar.Handle)&&next.Width>=160&&next.Height>=120;
169
+ }
170
+ int nextCell=8;while(next.Width/nextCell*(next.Height/(nextCell*2))>125000&&nextCell<16)nextCell+=2;
171
+ if(nextCell==14)nextCell=16;
172
+ int columns=next.Width/nextCell,rows=next.Height/(nextCell*2);
173
+ if(force||show!=visible||next!=canvas||nextCell!=cell){
174
+ visible=show;canvas=next;cell=nextCell;revision++;pet.Hide();
175
+ Program.Emit(new {type="viewport",revision=revision,visible=show&&!paused,selected=target!=null,columns=columns,rows=rows,cellWidth=cell,cellHeight=cell*2});
176
+ }
177
+ if(visible){
178
+ if(!bar.Visible)bar.Show();Native.Place(bar.Handle,new Rectangle(canvas.Right-bar.Width-10,canvas.Top+10,bar.Width,bar.Height));
179
+ if(paused)pet.Hide();
180
+ }else{pet.Hide();bar.Hide();}
181
+ }
182
+ List<TargetItem> Targets(){
183
+ List<TargetItem> result=new List<TargetItem>();uint mine=(uint)Process.GetCurrentProcess().Id;
184
+ Native.EnumWindows((hwnd,data)=>{
185
+ uint pid;Native.GetWindowThreadProcessId(hwnd,out pid);if(pid==mine||!Native.IsWindowVisible(hwnd))return true;
186
+ StringBuilder name=new StringBuilder(512);Native.GetWindowText(hwnd,name,name.Capacity);if(name.Length==0)return true;
187
+ string process="";try{using(Process p=Process.GetProcessById((int)pid)){process=p.ProcessName;}}catch{}
188
+ bool terminal=process.Equals("WindowsTerminal",StringComparison.OrdinalIgnoreCase)||process.IndexOf("wezterm",StringComparison.OrdinalIgnoreCase)>=0||process=="OpenConsole"||process=="conhost"||process=="mintty";
189
+ result.Add(new TargetItem{Window=hwnd,Process=pid,Title=(terminal?"[터미널] ":"")+name.ToString()+" · "+process,Terminal=terminal});return true;
190
+ },IntPtr.Zero);
191
+ result.Sort((a,b)=>a.Terminal==b.Terminal?String.Compare(a.Title,b.Title,StringComparison.CurrentCulture):a.Terminal?-1:1);return result;
192
+ }
193
+ void ChooseTarget(){
194
+ if(picking||quitting)return;picking=true;Track(true);
195
+ Form picker=new Form();pickerWindow=picker;
196
+ {
197
+ picker.Text="Retrieviewer · 함께 있을 창";picker.Size=new Size(650,230);picker.StartPosition=FormStartPosition.CenterScreen;picker.FormBorderStyle=FormBorderStyle.FixedDialog;picker.MaximizeBox=false;picker.MinimizeBox=false;picker.Font=new Font("Segoe UI",10);
198
+ picker.ShowInTaskbar=true;picker.TopMost=true;
199
+ Label hint=new Label();hint.Text="Codex를 사용하는 터미널 창을 선택하세요.\n내용을 읽지 않고 창의 위치·크기만 따라갑니다. 탭은 구별하지 않습니다.";hint.SetBounds(18,16,610,50);
200
+ ComboBox list=new ComboBox();list.DropDownStyle=ComboBoxStyle.DropDownList;list.SetBounds(18,76,600,28);foreach(TargetItem item in Targets())list.Items.Add(item);if(list.Items.Count>0)list.SelectedIndex=0;
201
+ Button refresh=new Button();refresh.Text="새로 고침";refresh.SetBounds(18,124,100,34);refresh.Click+=(s,e)=>{list.Items.Clear();foreach(TargetItem item in Targets())list.Items.Add(item);if(list.Items.Count>0)list.SelectedIndex=0;};
202
+ Button attach=new Button();attach.Text="이 창에 붙이기";attach.SetBounds(378,124,140,34);attach.Click+=(s,e)=>{if(list.SelectedItem!=null){target=(TargetItem)list.SelectedItem;paused=false;Program.Emit(new {type="pause",paused=false});picker.Close();}};
203
+ Button cancel=new Button();cancel.Text="취소";cancel.SetBounds(528,124,90,34);cancel.Click+=(s,e)=>picker.Close();
204
+ picker.Controls.AddRange(new Control[]{hint,list,refresh,attach,cancel});
205
+ picker.FormClosed+=(s,e)=>{pickerWindow=null;picking=false;Program.Emit(new {type="picker",visible=false});if(!quitting){if(target==null)status.Text="트레이에서 창 선택";tray.Text=target==null?"Retrieviewer · 창 선택":"Retrieviewer · 창에 붙어 있음";Track(true);}picker.Dispose();};
206
+ picker.Show();Native.ShowWindow(picker.Handle,5);Program.Emit(new {type="picker",visible=Native.IsWindowVisible(picker.Handle)});
207
+ }
208
+ }
209
+ void Quit(){if(quitting)return;quitting=true;clock.Stop();if(pickerWindow!=null)pickerWindow.Close();pet.Close();bar.Close();tray.Visible=false;tray.Dispose();Program.Emit(new {type="quit"});ExitThread();}
210
+ protected override void Dispose(bool disposing){if(disposing){clock.Dispose();pet.Dispose();bar.Dispose();tray.Dispose();if(testTarget!=null)testTarget.Dispose();if(mutex!=null){mutex.ReleaseMutex();mutex.Dispose();mutex=null;}}base.Dispose(disposing);}
211
+ }
212
+
213
+ static class Program {
214
+ static readonly object outputLock=new object();
215
+ internal static TextReader Input;static TextWriter Output;
216
+ internal static void Emit(object value){lock(outputLock){try{Output.WriteLine(new JavaScriptSerializer().Serialize(value));Output.Flush();}catch{Environment.Exit(0);}}}
217
+ internal static int Int(Dictionary<string,object> m,string key){return Convert.ToInt32(m[key]);}
218
+ internal static string Str(Dictionary<string,object> m,string key){return Convert.ToString(m[key]);}
219
+ internal static bool Bool(Dictionary<string,object> m,string key){return Convert.ToBoolean(m[key]);}
220
+ [STAThread] static int Main(string[] args){
221
+ try{
222
+ // GUI-subsystem processes have redirected handles but no console code
223
+ // page. Setting Console.*Encoding can throw before startup on Windows.
224
+ Input=new StreamReader(Console.OpenStandardInput(),new UTF8Encoding(false),false);
225
+ Output=new StreamWriter(Console.OpenStandardOutput(),new UTF8Encoding(false));
226
+ try{Native.SetProcessDpiAwarenessContext(new IntPtr(-4));}catch(EntryPointNotFoundException){Native.SetProcessDPIAware();}
227
+ Application.EnableVisualStyles();Application.SetCompatibleTextRenderingDefault(false);
228
+ if(args.Length==2&&args[0]=="--self-test")return SelfTest(args[1]);
229
+ bool ipcTest=args.Length==1&&args[0]=="--ipc-test";
230
+ if(args.Length!=0&&!ipcTest)throw new Exception("Unsupported native painter argument");
231
+ // Diagnostics own only off-screen windows and must not stop or contend
232
+ // with a user's running companion during a release verification.
233
+ string mutexName="Local\\BiteFoundry.Retrieviewer.Overlay."+System.Security.Principal.WindowsIdentity.GetCurrent().User.Value+(ipcTest?".Diagnostics."+Process.GetCurrentProcess().Id:"");
234
+ bool first;Mutex single=new Mutex(true,mutexName,out first);
235
+ if(!first){single.Dispose();Emit(new {type="error",message="A Retrieviewer companion is already running"});return 2;}
236
+ using(Companion context=new Companion(single,ipcTest))Application.Run(context);return 0;
237
+ }catch(Exception error){Emit(new {type="error",message=error.Message});return 1;}
238
+ }
239
+ static int SelfTest(string proof){
240
+ // Uses only our own off-screen windows. Does NOT click/type in or capture a user application.
241
+ IntPtr foreground=Native.GetForegroundWindow();
242
+ Dictionary<string,object> frame=new JavaScriptSerializer().Deserialize<Dictionary<string,object>>(Input.ReadLine());
243
+ using(QuietWindow target=new QuietWindow())using(PetWindow pet=new PetWindow()){
244
+ target.Bounds=new Rectangle(-12000,-12000,1600,1000);target.Show();Application.DoEvents();
245
+ Rectangle first=Native.ClientBounds(target.Handle);using(Bitmap bitmap=pet.PaintFrame(frame,8,first)){bitmap.Save(proof,ImageFormat.Png);}
246
+ int styles=Native.GetWindowLong(pet.Handle,-20);bool clickThrough=(styles&0x20)!=0,noActivate=(styles&0x08000000)!=0,layered=(styles&0x80000)!=0;
247
+ target.Bounds=new Rectangle(-11000,-11500,1800,1100);Application.DoEvents();Rectangle moved=Native.ClientBounds(target.Handle);
248
+ using(Bitmap bitmap=pet.PaintFrame(frame,8,moved)){}
249
+ Native.RECT actual;Native.GetWindowRect(pet.Handle,out actual);
250
+ int expectedX=moved.Left+Int(frame,"x")*8,expectedY=moved.Top+Int(frame,"y")*16;
251
+ bool follows=actual.Left==expectedX&&actual.Top==expectedY;
252
+ Native.ShowWindow(target.Handle,7);Application.DoEvents();bool minimized=Native.IsIconic(target.Handle);pet.Hide();bool hidden=!pet.Visible;
253
+ Native.ShowWindow(target.Handle,4);Application.DoEvents();bool restored=!Native.IsIconic(target.Handle);
254
+ bool focus=Native.GetForegroundWindow()==foreground;
255
+ bool passed=clickThrough&&noActivate&&layered&&follows&&minimized&&hidden&&restored&&focus&&pet.PaintedDots>0;
256
+ Emit(new {type="self-test",passed=passed,clickThroughStyle=clickThrough,noActivateStyle=noActivate,layered=layered,followGeometry=follows,expectedX=expectedX,expectedY=expectedY,actualX=actual.Left,actualY=actual.Top,minimize=minimized,hide=hidden,restore=restored,foregroundPreserved=focus,dots=pet.PaintedDots,proof=proof});return passed?0:1;
257
+ }
258
+ }
259
+ }
@@ -0,0 +1,87 @@
1
+ // Explicit Windows companion launcher. Never imports the pet on help/status/stop.
2
+ import {spawn,execFile} from 'node:child_process';
3
+ import {promisify} from 'node:util';
4
+ import {readFile,mkdir,lstat,unlink,open,rename,readdir} from 'node:fs/promises';
5
+ import {createHash,randomUUID} from 'node:crypto';
6
+ import {join,dirname} from 'node:path';
7
+ import {fileURLToPath} from 'node:url';
8
+ import {createConnection} from 'node:net';
9
+ import {getConfigPaths} from '../../retrieviewer-config.mjs';
10
+ const exec=promisify(execFile);
11
+ const SOURCE=new URL('./Overlay.cs',import.meta.url);
12
+ export function desktopPaths(options={}){const root=join(getConfigPaths(options).root,'desktop');return {root,session:join(root,'session.json')};}
13
+ export async function noLink(path){try{if((await lstat(path)).isSymbolicLink())throw Error('Refusing desktop symlink: '+path);}catch(e){if(e.code!=='ENOENT')throw e;}}
14
+ export async function prepareRoot(options={}){
15
+ const parent=getConfigPaths(options).root,{root}=desktopPaths(options);await noLink(dirname(parent));await noLink(parent);await noLink(root);await mkdir(root,{recursive:true,mode:0o700});return desktopPaths(options);
16
+ }
17
+ export async function readSession(options={}){
18
+ const {root,session}=desktopPaths(options);await noLink(dirname(root));await noLink(root);await noLink(session);
19
+ let data;try{data=JSON.parse(await readFile(session,'utf8'));}catch(e){if(e.code==='ENOENT')return null;throw e;}
20
+ if(data?.owner!=='bitefoundry-retrieviewer-overlay-v1'||!Number.isInteger(data.pid)||data.pid<=0||!/^\\\\\.\\pipe\\retrieviewer-overlay-[a-f0-9-]{36}$/.test(data.pipe)||!/^[a-f0-9]{64}$/.test(data.token))throw Error('Unrecognized desktop session; not modifying it');
21
+ return data;
22
+ }
23
+ export function requestSession(session,command){
24
+ return new Promise((resolve,reject)=>{
25
+ const socket=createConnection(session.pipe);let text='',done=false;
26
+ const finish=(error,value)=>{if(done)return;done=true;socket.destroy();error?reject(error):resolve(value);};
27
+ socket.setTimeout(1800,()=>finish(Error('Desktop control timed out')));
28
+ socket.on('connect',()=>socket.write(JSON.stringify({token:session.token,command})+'\n'));
29
+ socket.on('data',bytes=>{text+=bytes.toString('utf8');if(text.length>65536)return finish(Error('Oversized desktop response'));if(text.includes('\n'))try{finish(null,JSON.parse(text.split('\n')[0]));}catch(e){finish(e);}});
30
+ socket.on('error',e=>finish(e));socket.on('end',()=>{if(!done)finish(Error('Desktop control closed without a response'));});
31
+ });
32
+ }
33
+ export async function desktopStatus(options={}){
34
+ const session=await readSession(options);if(!session)return {running:false};
35
+ try{return await requestSession(session,'status');}catch(error){let alive=true;try{process.kill(session.pid,0);}catch(e){if(e.code==='ESRCH')alive=false;}
36
+ return {running:alive,reachable:false,stale:!alive,pid:session.pid,reason:error.code??error.message};}
37
+ }
38
+ export async function stopDesktop(options={}){
39
+ const session=await readSession(options);if(!session)return {running:false,stopped:true};
40
+ const result=await desktopStatus(options);
41
+ if(result.stale){await unlink(desktopPaths(options).session);return {running:false,stopped:true,staleSessionRemoved:true};}
42
+ if(result.reachable===false)throw Error('Desktop process is not responding; close it from its tray icon. No PID was killed.');
43
+ await requestSession(session,'stop');
44
+ // The stop response acknowledges the request, not native process termination.
45
+ // Wait before advertising success so a following cleanup cannot race an in-use exe.
46
+ for(let i=0;i<60;i++){
47
+ try{process.kill(session.pid,0);}catch(e){if(e.code==='ESRCH')return {running:false,stopped:true};throw e;}
48
+ await new Promise(resolve=>setTimeout(resolve,100));
49
+ }
50
+ throw Error('Desktop accepted stop but has not exited; cache preserved. Retry after it closes.');
51
+ }
52
+ export async function buildHelper(options={}){
53
+ if(process.platform!=='win32')throw Error('The transparent companion currently supports Windows only');
54
+ const {root}=await prepareRoot(options),source=await readFile(SOURCE),hash=createHash('sha256').update(source).digest('hex').slice(0,20);
55
+ const executable=join(root,`overlay-${hash}.exe`);await noLink(executable);
56
+ try{await lstat(executable);return executable;}catch(e){if(e.code!=='ENOENT')throw e;}
57
+ const compiler=join(process.env.SystemRoot??'C:\\Windows','Microsoft.NET','Framework64','v4.0.30319','csc.exe');
58
+ const temporary=join(root,`build-${randomUUID()}.exe`);
59
+ try{
60
+ await exec(compiler,['/nologo','/target:winexe','/platform:anycpu','/optimize+','/codepage:65001','/reference:System.Windows.Forms.dll','/reference:System.Drawing.dll','/reference:System.Web.Extensions.dll','/out:'+temporary,fileURLToPath(SOURCE)],{windowsHide:true,timeout:30000,maxBuffer:1024*1024});
61
+ await rename(temporary,executable);
62
+ }catch(error){await unlink(temporary).catch(()=>{});throw Error('Windows overlay build failed: '+(error.stdout??error.message));}
63
+ return executable;
64
+ }
65
+ export async function startDesktop(options={}){
66
+ const status=await desktopStatus(options);if(status.running)return {...status,alreadyRunning:true};
67
+ if(status.stale)await stopDesktop(options);
68
+ const executable=await buildHelper(options);
69
+ return new Promise((resolve,reject)=>{
70
+ const child=spawn(process.execPath,[fileURLToPath(new URL('./worker.mjs',import.meta.url)),executable],{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],env:{...process.env,RETRIEVIEWER_HOME:getConfigPaths(options).root}});
71
+ let settled=false;const finish=(error,message)=>{if(settled)return;settled=true;clearTimeout(timer);if(child.connected)child.disconnect();child.unref();error?reject(error):resolve(message);};
72
+ const timer=setTimeout(()=>{if(child.connected)child.send({command:'abort'});finish(Error('Desktop startup timed out; no agent settings changed'));},20000);
73
+ child.on('error',error=>finish(error));child.on('message',message=>message.error?finish(Error(message.error)):finish(null,message));child.on('exit',code=>finish(Error('Desktop exited during startup ('+code+')')));
74
+ });
75
+ }
76
+ export async function cleanDesktop(options={}){
77
+ const status=await desktopStatus(options);if(status.running)throw Error('Stop the desktop companion before removing its cache');
78
+ const {root,session}=desktopPaths(options);if(status.stale)await unlink(session);
79
+ let files;try{await noLink(root);files=await readdir(root);}catch(e){if(e.code==='ENOENT')return {removed:[]};throw e;}
80
+ const removed=[];
81
+ for(const name of files)if(/^overlay-[a-f0-9]{20}\.exe$/.test(name)||/^build-[a-f0-9-]{36}\.exe$/.test(name)){
82
+ const path=join(root,name);await noLink(path);if(!(await lstat(path)).isFile())throw Error('Refusing non-file desktop cache entry');await unlink(path);removed.push(name);
83
+ }
84
+ // Unknown files and all other Retrieviewer/agent settings are deliberately kept.
85
+ return {removed,preservedOtherFiles:true};
86
+ }
87
+ export async function removeDesktop(options={}){await stopDesktop(options);return {stopped:true,...await cleanDesktop(options)};}
@@ -0,0 +1,39 @@
1
+ // Pure transport helpers: no art, timers, processes or I/O on import.
2
+ export function viewportOf(message){
3
+ if(message?.type!=='viewport'||!Number.isSafeInteger(message.revision)||message.revision<1||typeof message.visible!=='boolean')throw Error('Invalid overlay viewport');
4
+ if(!message.visible)return null;
5
+ const {columns,rows,cellWidth,cellHeight}=message;
6
+ if(!Number.isInteger(columns)||!Number.isInteger(rows)||columns<1||rows<1||columns*rows>125000||![6,8,10,12,16].includes(cellWidth)||cellHeight!==cellWidth*2)throw Error('Invalid overlay grid');
7
+ return {columns,rows,cellAspect:.5,revision:message.revision,cellWidth,cellHeight};
8
+ }
9
+ export function cropFrame(frame,revision){
10
+ const lines=frame.text.split('\n');
11
+ if(lines.length!==frame.rows||lines.some(s=>s.length!==frame.columns))throw Error('Invalid frame dimensions');
12
+ let left=frame.columns,top=frame.rows,right=-1,bottom=-1;
13
+ for(let y=0;y<lines.length;y++)for(let x=0;x<lines[y].length;x++){
14
+ const c=lines[y].charCodeAt(x);if(c<0x2800||c>0x28ff)throw Error('Only Braille cells may cross the overlay bridge');
15
+ if(c!==0x2800){left=Math.min(left,x);right=Math.max(right,x);top=Math.min(top,y);bottom=Math.max(bottom,y);}
16
+ }
17
+ return {type:'frame',revision,x:right<0?0:left,y:right<0?0:top,columns:right<0?0:right-left+1,rows:right<0?0:bottom-top+1,
18
+ text:right<0?'':lines.slice(top,bottom+1).map(s=>s.slice(left,right+1)).join('\n')};
19
+ }
20
+
21
+ // Request/ack rendering: never queue obsolete frames behind a slow native painter.
22
+ export function createFramePump({pet,send,now=()=>performance.now()}){
23
+ let viewport=null,inFlight=false,last=null,revision=0,paused=false,disposed=false;
24
+ const alive=()=>{if(disposed)throw Error('Overlay pump is disposed');};
25
+ return {
26
+ viewport(message){alive();const next=viewportOf(message);revision=message.revision;viewport=next;last=null;},
27
+ pause(value){alive();paused=!!value;last=null;},
28
+ ack(){alive();inFlight=false;},
29
+ tick(){
30
+ alive();if(inFlight||!viewport||paused)return false;
31
+ const t=now(),dt=last===null?0:Math.max(0,(t-last)/1000);last=t;
32
+ pet.step(dt);const frame=pet.frame(viewport);inFlight=true;
33
+ try{send(cropFrame(frame,revision));}catch(error){inFlight=false;throw error;}return true;
34
+ },
35
+ interact(kind){alive();if(!['ball','treat'].includes(kind))throw Error('Invalid overlay interaction');return pet.interact(kind);},
36
+ get hidden(){return !viewport||paused;},
37
+ dispose(){if(!disposed){disposed=true;viewport=null;pet.dispose();}},
38
+ };
39
+ }
@@ -0,0 +1,60 @@
1
+ // Private detached worker, started only by the explicit `desktop` command.
2
+ import {spawn} from 'node:child_process';
3
+ import {createServer} from 'node:net';
4
+ import {createInterface} from 'node:readline';
5
+ import {open,unlink,readFile} from 'node:fs/promises';
6
+ import {randomBytes,randomUUID} from 'node:crypto';
7
+ import {prepareRoot,noLink} from './desktop.mjs';
8
+ import {createFramePump} from './protocol.mjs';
9
+
10
+ let helper,pump,timer,server,session,paths,owned=false,stopping=false,frames=0,paintedFrames=0,visible=false,selected=false,pickerVisible=false,pendingViewport=null,lastInteraction=null;
11
+ const sendParent=message=>{if(process.connected)process.send(message);};
12
+ async function stop(){
13
+ if(stopping)return;stopping=true;clearInterval(timer);pump?.dispose();
14
+ if(helper&&!helper.killed){try{helper.stdin.end(JSON.stringify({type:'quit'})+'\n');}catch{}}
15
+ if(server)server.close();
16
+ if(owned)try{const current=JSON.parse(await readFile(paths.session,'utf8'));if(current.token===session.token)await unlink(paths.session);}catch{}
17
+ // A wedged painter is our own child only, never the selected terminal.
18
+ const watchdog=setTimeout(()=>{helper?.kill();process.exit(0);},2500);watchdog.unref();
19
+ if(!helper||helper.exitCode!==null)process.exitCode=0;
20
+ }
21
+ process.on('SIGTERM',stop);process.on('SIGINT',stop);process.on('message',m=>{if(m?.command==='abort')stop();});
22
+ try{
23
+ paths=await prepareRoot();await noLink(paths.session);
24
+ session={owner:'bitefoundry-retrieviewer-overlay-v1',pid:process.pid,pipe:'\\\\.\\pipe\\retrieviewer-overlay-'+randomUUID(),token:randomBytes(32).toString('hex')};
25
+ const record=await open(paths.session,'wx',0o600);owned=true;try{await record.writeFile(JSON.stringify(session)+'\n');}finally{await record.close();}
26
+ server=createServer(socket=>{
27
+ socket.setTimeout(1500,()=>socket.destroy());let body='';
28
+ socket.on('error',()=>{});socket.on('data',bytes=>{
29
+ body+=bytes.toString('utf8');if(body.length>2048)return socket.destroy();if(!body.includes('\n'))return;
30
+ let command;try{command=JSON.parse(body.split('\n')[0]);}catch{return socket.destroy();}
31
+ if(command.token!==session.token||!['status','stop'].includes(command.command))return socket.destroy();
32
+ socket.end(JSON.stringify(command.command==='stop'?{stopped:true}:{running:true,reachable:true,selected,visible,pickerVisible,frames,paintedFrames,lastInteraction,pid:process.pid,painterPid:helper?.pid,rssBytes:process.memoryUsage().rss})+'\n');
33
+ if(command.command==='stop')stop();
34
+ });
35
+ });
36
+ await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(session.pipe,resolve);});
37
+ // GUI-subsystem exe: no console to hide. SW_HIDE here also hides its first UI.
38
+ helper=spawn(process.argv[2],process.argv[3]==='--ipc-test'?['--ipc-test']:[],{windowsHide:false,stdio:['pipe','pipe','pipe']});
39
+ helper.stdin.on('error',()=>stop());helper.stderr.resume();helper.once('error',error=>{sendParent({error:error.message});stop();});
40
+ helper.once('exit',code=>{if(!stopping)sendParent({error:'Windows painter exited ('+code+')'});stop();});
41
+ const write=message=>{if(!stopping&&!helper.stdin.destroyed)helper.stdin.write(JSON.stringify(message)+'\n');};
42
+ const reader=createInterface({input:helper.stdout});
43
+ reader.on('line',line=>{
44
+ if(line.length>100000)return stop();let m;try{m=JSON.parse(line);}catch{return stop();}
45
+ try{
46
+ if(m.type==='ready'){pickerVisible=m.pickerVisible===true;sendParent({running:true,windowPicker:pickerVisible,mode:'windows-overlay',pid:process.pid});}
47
+ else if(m.type==='picker')pickerVisible=m.visible===true;
48
+ else if(m.type==='viewport'){pendingViewport=m;visible=m.visible;selected=m.selected===true;pump?.viewport(m);}
49
+ else if(m.type==='ack'){frames++;if(m.painted)paintedFrames++;pump?.ack();}
50
+ else if(m.type==='pause')pump?.pause(m.paused);
51
+ else if(m.type==='interact'){const accepted=pump?.interact(m.kind)??false;lastInteraction={kind:m.kind,accepted};write({type:'interaction',kind:m.kind,accepted});}
52
+ else if(m.type==='quit')stop();
53
+ }catch(error){write({type:'error',message:error.message});stop();}
54
+ });
55
+ const {createPet}=await import('../../retrieviewer.mjs');const pet=await createPet();
56
+ if(stopping)pet.dispose();else{
57
+ pump=createFramePump({pet,send:write});if(pendingViewport)pump.viewport(pendingViewport);
58
+ write({type:'loaded'});timer=setInterval(()=>{try{pump.tick();}catch(error){write({type:'error',message:error.message});stop();}},1000/30);
59
+ }
60
+ }catch(error){sendParent({error:error.message});await stop();}
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@bitefoundry/retrieviewer",
3
- "version": "0.0.1",
4
- "description": "Embeddable autonomous Braille retriever for TUI panes; Q5 art included, host-owned input and screen.",
3
+ "version": "0.0.2",
4
+ "description": "A fluffy Braille desktop companion for Windows apps, plus an embeddable TUI pet. Give it a ball or a treat.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/BiteFoundry",
7
- "keywords": ["tui", "braille", "pet", "retriever", "opencode"],
7
+ "keywords": ["tui", "braille", "pet", "retriever", "opencode", "desktop-pet", "windows", "overlay"],
8
8
  "publishConfig": {
9
9
  "access": "public",
10
10
  "registry": "https://registry.npmjs.org/",
@@ -52,6 +52,11 @@
52
52
  "retrieviewer-config.mjs",
53
53
  "integrations/opencode/tui.tsx",
54
54
  "integrations/opencode/runtime.mjs",
55
+ "integrations/windows-overlay/protocol.mjs",
56
+ "integrations/windows-overlay/desktop.mjs",
57
+ "integrations/windows-overlay/worker.mjs",
58
+ "integrations/windows-overlay/Overlay.cs",
59
+ "WINDOWS-COMPANION.md",
55
60
  "INSTALLING.md",
56
61
  "PLUGIN.md",
57
62
  "EMBEDDING.md",
@@ -114,6 +119,7 @@
114
119
  "terminal": "node terminal.mjs",
115
120
  "pet": "node puppy-terminal.mjs",
116
121
  "pet:companion": "node examples/live.mjs",
122
+ "pet:desktop": "node retrieviewer-cli.mjs desktop",
117
123
  "test": "node --test \"*.test.mjs\"",
118
124
  "test:locomotion": "node check-locomotion.mjs",
119
125
  "test:package": "node check-pet-package.mjs",
@@ -13,11 +13,18 @@ Usage:
13
13
  retrieviewer doctor [--agent ID] [--json]
14
14
  retrieviewer agents [--json]
15
15
  retrieviewer preview [--original]
16
+ retrieviewer desktop
17
+ retrieviewer desktop-status [--json]
18
+ retrieviewer desktop-stop
19
+ retrieviewer desktop-clean --yes
20
+ retrieviewer desktop-remove --yes
16
21
 
17
22
  Enable opens a first-run selection/confirmation wizard on a real terminal.
18
23
  Non-interactive changes require --agent and --yes. Unsupported native renderers
19
24
  are refused: MCP connectivity alone is never treated as in-screen rendering.
20
25
  No command changes credentials, shell profiles or agent executables.
26
+ Desktop is a separate Windows overlay, NOT a Codex plugin. Choose a window once;
27
+ use Codex normally. Exit from its tray icon or desktop-stop. No auto-start is added.
21
28
  `;
22
29
  export function parseArguments(args){
23
30
  const [command='help',...rest]=args,options={};
@@ -30,14 +37,20 @@ export function parseArguments(args){
30
37
  else throw Error('Unknown argument: '+token);
31
38
  }
32
39
  if(options.agent&&!AGENTS.some(a=>a.id===options.agent))throw Error('Unknown agent: '+options.agent);
33
- if(!['help','--help','-h','enable','disable','disconnect','status','doctor','agents','preview'].includes(command))throw Error('Unknown command: '+command);
34
- const allowed={enable:['agent','yes','json','dry-run'],disconnect:['agent','yes','json','dry-run'],disable:['agent','json'],status:['agent','json'],doctor:['agent','json'],agents:['json'],preview:['original']};
40
+ if(!['help','--help','-h','enable','disable','disconnect','status','doctor','agents','preview','desktop','desktop-status','desktop-stop','desktop-clean','desktop-remove'].includes(command))throw Error('Unknown command: '+command);
41
+ const allowed={enable:['agent','yes','json','dry-run'],disconnect:['agent','yes','json','dry-run'],disable:['agent','json'],status:['agent','json'],doctor:['agent','json'],agents:['json'],preview:['original'],'desktop-status':['json'],'desktop-clean':['yes'],'desktop-remove':['yes']};
35
42
  for(const key of Object.keys(options))if(!(allowed[command]??[]).includes(key))throw Error(`--${key} is not supported by ${command}`);
36
43
  return {command,options};
37
44
  }
38
- export async function runCli(args,{input=process.stdin,output=process.stdout,error=process.stderr,installer=createInstaller()}={}){
45
+ export async function runCli(args,{input=process.stdin,output=process.stdout,error=process.stderr,installer=createInstaller(),desktop}={}){
39
46
  const {command,options}=parseArguments(args),emit=value=>output.write(JSON.stringify(value,null,2)+'\n');
40
47
  if(['help','--help','-h'].includes(command)){output.write(HELP);return 0;}
48
+ if(command.startsWith('desktop')){
49
+ if(['desktop-clean','desktop-remove'].includes(command)&&!options.yes)throw Error('Use '+command+' --yes to remove only the companion cache.');
50
+ const api=desktop??await import('./integrations/windows-overlay/desktop.mjs');
51
+ const action={'desktop':'startDesktop','desktop-status':'desktopStatus','desktop-stop':'stopDesktop','desktop-clean':'cleanDesktop','desktop-remove':'removeDesktop'}[command];
52
+ emit(await api[action]());return 0;
53
+ }
41
54
  if(command==='preview'){await import('./examples/live.mjs');return 0;}
42
55
  if(['status','doctor','agents'].includes(command)){
43
56
  const result=command==='agents'?await installer.list():await installer[command](options.agent);