residoo 0.25.0 → 0.26.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/README.md CHANGED
@@ -206,15 +206,16 @@ brew install residoo
206
206
  The Homebrew formula installs the exact tarball published to npm (sha256
207
207
  verified): same bits, not a second build.
208
208
 
209
- **macOS, no terminal needed for the install step itself:** download
209
+ **macOS, no terminal needed for install or uninstall:** download
210
210
  `residoo-<version>.pkg` from the
211
211
  [latest release](https://github.com/dandovdub/residoo/releases/latest)
212
- and double-click it. It's unsigned (no Apple Developer ID -- right-click
213
- > Open once to get past Gatekeeper's "unidentified developer" warning),
214
- and it still needs Node.js present on the machine (it runs
212
+ and double-click it to install, `residoo-uninstall-<version>.pkg` to
213
+ remove it. Both are unsigned (no Apple Developer ID -- right-click >
214
+ Open once to get past Gatekeeper's "unidentified developer" warning),
215
+ and installing still needs Node.js present on the machine (it runs
215
216
  `npm install -g residoo` on your behalf, it doesn't bundle a Node
216
217
  runtime) -- see [packaging/macos-pkg](packaging/macos-pkg/README.md) for
217
- exactly what it does and how it was verified.
218
+ exactly what each one does and how both were verified.
218
219
 
219
220
  Requires Node.js 18+ (22.5+ for the SQLite-backed sources listed in
220
221
  [docs/sources.md](docs/sources.md); residoo still runs fine without it).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "CloudRoam (https://cloudroam.io)",
package/src/cli.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  } = require("./rotation");
14
14
  const { startWatch, isTailable } = require("./watch");
15
15
  const { startDashboardServer, openBrowser } = require("./dashboard");
16
+ const { startWindowsTray } = require("./notify");
16
17
  const { startMcpServer } = require("./mcp");
17
18
  const { buildTools } = require("./mcpTools");
18
19
  const { runGuard: runGuardEngine, buildHookConfig } = require("./guard");
@@ -216,8 +217,17 @@ Watch:
216
217
  nobody is watching a terminal for needs more than
217
218
  a printed line. Never fires for a re-exposure of
218
219
  something already seen, and never in --json mode.
219
- Ctrl+C stops cleanly and prints a session summary (skipped with --json,
220
- where the same information is one final NDJSON event).
220
+ --tray Windows only: a persistent "residoo is watching"
221
+ tray icon for the life of this session, separate
222
+ from --no-notify's own per-finding balloon-tip
223
+ alerts (which still fire independently). No
224
+ equivalent stock mechanism exists on macOS/Linux
225
+ yet -- disclosed with a one-line message there,
226
+ not silently ignored. Right-click > Hide icon to
227
+ dismiss it without stopping the watch itself.
228
+ Ctrl+C stops cleanly, closes the tray icon if one was shown, and prints a
229
+ session summary (skipped with --json, where the same information is one
230
+ final NDJSON event).
221
231
 
222
232
  Dashboard:
223
233
  residoo dashboard the exact "scan --html" report, served live at
@@ -823,6 +833,7 @@ async function runWatch(args) {
823
833
  const includePii = args.includes("--include-pii");
824
834
  const includeInjection = args.includes("--include-injection");
825
835
  const noNotify = args.includes("--no-notify");
836
+ const wantsTray = args.includes("--tray");
826
837
 
827
838
  let intervalSeconds = 5;
828
839
  const intervalArg = argValue(args, "--interval");
@@ -846,6 +857,22 @@ async function runWatch(args) {
846
857
 
847
858
  if (!wantsJson) printWatchBanner(sources);
848
859
 
860
+ // --tray: a PERSISTENT status-presence icon, deliberately decoupled from
861
+ // the per-finding balloon-tip alerts --no-notify already controls (see
862
+ // notify.js's startWindowsTray docstring for why merging them would need
863
+ // real inter-process communication this project has no way to verify).
864
+ // Windows-only, disclosed rather than silently ignored elsewhere: a real
865
+ // stock mechanism exists there (PowerShell + .NET's own NotifyIcon); macOS
866
+ // has none without a compiled app, and this flag doesn't attempt one.
867
+ let trayProcess = null;
868
+ if (wantsTray) {
869
+ if (process.platform === "win32") {
870
+ trayProcess = startWindowsTray("residoo is watching");
871
+ } else if (!wantsJson) {
872
+ process.stderr.write("--tray is Windows-only (no equivalent stock mechanism on this platform yet) -- ignored on this run.\n");
873
+ }
874
+ }
875
+
849
876
  const { promise, stop } = startWatch({
850
877
  sources,
851
878
  options: { includeNoisy, includeSuppressed, verify, noColor, includePii, includeInjection, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
@@ -863,6 +890,10 @@ async function runWatch(args) {
863
890
  const onSignal = () => {
864
891
  if (signalled) return;
865
892
  signalled = true;
893
+ // Not .unref()'d when spawned (see startWindowsTray's own docstring),
894
+ // specifically so this kill() can still reach it here -- a spawned
895
+ // child Node has already lost its handle to can never be stopped again.
896
+ if (trayProcess) trayProcess.kill();
866
897
  printFinalSummary(stop());
867
898
  };
868
899
  process.once("SIGINT", onSignal);
@@ -871,6 +902,7 @@ async function runWatch(args) {
871
902
  const stats = await promise;
872
903
  process.removeListener("SIGINT", onSignal);
873
904
  process.removeListener("SIGTERM", onSignal);
905
+ if (trayProcess && !signalled) trayProcess.kill();
874
906
  // promise can also resolve because something ELSE called stop() (not
875
907
  // possible from outside this function today, but the contract allows
876
908
  // it) -- print the summary exactly once regardless of which path got here.
package/src/notify.js CHANGED
@@ -97,4 +97,89 @@ function notifyWindows(title, message) {
97
97
  child.unref();
98
98
  }
99
99
 
100
- module.exports = { notifyDesktop };
100
+ /**
101
+ * A PERSISTENT Windows tray icon -- genuinely different from `notifyWindows`
102
+ * above, not a variant of it: that function is fire-and-forget (one balloon,
103
+ * then the process disposes itself and exits); this one stays alive and
104
+ * visible for as long as the CALLER wants it to (`residoo watch --tray`,
105
+ * the standing-presence "residoo is watching" indicator this project's own
106
+ * platform-scope.md records as a real, scoped follow-up). The two are
107
+ * deliberately DECOUPLED, not merged into one mechanism: this function only
108
+ * shows a static icon + tooltip; actual per-finding alerts keep firing
109
+ * through the existing, already-proven `notifyWindows` balloon-tip path,
110
+ * completely independently. Combining them into one process would mean
111
+ * finding a way to push live UPDATES into an already-running PowerShell
112
+ * process (a named pipe, a polled state file) -- real inter-process-
113
+ * communication complexity this project has no way to verify without a
114
+ * real Windows machine, so it was deliberately left out of scope rather
115
+ * than shipped unverified. A static presence icon needs no such channel.
116
+ *
117
+ * API surface verified directly against Microsoft's own current docs
118
+ * (learn.microsoft.com, fetched 2026-09-07), the same bar every other
119
+ * Windows-specific function in this project holds to, and likewise NOT
120
+ * live-tested against a real Windows install:
121
+ * - `NotifyIcon.ContextMenuStrip` (not the older, pre-.NET-2.0
122
+ * `ContextMenu`/`MenuItem` classes) is the current, non-deprecated
123
+ * property, listed through the windowsdesktop-11.0 moniker.
124
+ * - `SystemIcons.Shield` is a real, current static property ("an Icon
125
+ * object that contains the shield icon") -- used here instead of
126
+ * bundling a custom .ico file, matching zero-dependency the same way
127
+ * `notifyWindows` reuses `SystemIcons.Information`.
128
+ * - `NotifyIcon.Text` (the tooltip) has a REAL, documented, THROWING
129
+ * limit: Microsoft's own docs give an exact table -- 63 characters
130
+ * for .NET Framework and .NET 5/Core 3.0-3.1, 127 for .NET 6+. Windows
131
+ * PowerShell (`powershell.exe`, what this project shells out to
132
+ * everywhere, never `pwsh.exe`) runs on .NET Framework, so 63 is the
133
+ * applicable limit, and exceeding it throws `ArgumentException` --
134
+ * not a cosmetic detail, a real crash this function truncates against
135
+ * before it can happen.
136
+ *
137
+ * Lifecycle, the one place this genuinely departs from every other spawn
138
+ * in this file: NOT `.unref()`'d, because the caller (`watch.js`) needs
139
+ * to `.kill()` this exact child process on its own SIGINT/SIGTERM
140
+ * shutdown -- an unref'd handle a caller has already discarded can't be
141
+ * reached again later. `[System.Windows.Forms.Application]::Run()` blocks
142
+ * the spawned PowerShell process in its own message loop for as long as
143
+ * the icon should stay visible; the "Hide icon" context-menu item calls
144
+ * `Application.Exit()` so a user can dismiss it independently of whether
145
+ * `residoo watch` itself keeps running.
146
+ */
147
+ function startWindowsTray(tooltip) {
148
+ if (process.platform !== "win32") return null;
149
+ // Unlike notifyDesktop's callers, nothing wraps a call to this function
150
+ // in an outer try/catch -- runWatch calls it directly, and this returns
151
+ // a real value (the child, or null) callers branch on, so the "never
152
+ // throw" contract has to be enforced right here, not borrowed from a
153
+ // caller the way notifyWindows borrows notifyDesktop's. The whole body
154
+ // is inside this one try, not just the spawn call: `String(tooltip)`
155
+ // itself can throw -- an object whose own `toString` property isn't a
156
+ // function fails JavaScript's ToPrimitive coercion before anything else
157
+ // runs, the exact real (fast-check-found, not hypothetical) shape
158
+ // cve.js's parseVersion hit and was fixed for earlier this same
159
+ // project -- confirmed directly here too before shipping, not assumed
160
+ // fixed by analogy.
161
+ try {
162
+ const esc = (s) => String(s).replace(/'/g, "''");
163
+ const truncated = String(tooltip).slice(0, 63); // NotifyIcon.Text's real, throwing limit on .NET Framework -- see docstring
164
+ const script =
165
+ "Add-Type -AssemblyName System.Windows.Forms; " +
166
+ "Add-Type -AssemblyName System.Drawing; " +
167
+ "$ni = New-Object System.Windows.Forms.NotifyIcon; " +
168
+ "$ni.Icon = [System.Drawing.SystemIcons]::Shield; " +
169
+ `$ni.Text = '${esc(truncated)}'; ` +
170
+ "$ni.Visible = $true; " +
171
+ "$menu = New-Object System.Windows.Forms.ContextMenuStrip; " +
172
+ "$hideItem = New-Object System.Windows.Forms.ToolStripMenuItem 'Hide icon'; " +
173
+ "$hideItem.add_Click({ $ni.Visible = $false; $ni.Dispose(); [System.Windows.Forms.Application]::Exit() }); " +
174
+ "[void]$menu.Items.Add($hideItem); " +
175
+ "$ni.ContextMenuStrip = $menu; " +
176
+ "[System.Windows.Forms.Application]::Run()";
177
+ const child = cp.spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script], { stdio: "ignore" });
178
+ child.on("error", () => {}); // binary missing or spawn failed: never throw
179
+ return child;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ module.exports = { notifyDesktop, startWindowsTray };