@hraness/ghostget 0.18.12 → 0.18.13

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
@@ -7,6 +7,18 @@ Historical entries retain their original delivery coordinates.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.18.13
11
+
12
+ - Move the menu companion onto the shared desktop-foundation runner, retaining
13
+ its revision-checked account controls and bounded local output listing.
14
+ - Add explicit menu links for free Ghostget updates and optional paid development
15
+ support. Hraness Accounts owns the browser confirmation; the menu sends no
16
+ local account details or email address.
17
+ - Add the shared optional support link to the static website footer, retaining
18
+ the existing production-only Ghostget newsletter form.
19
+ - Retain timeout escalation until the direct iMessage RPC process group exits,
20
+ including descendants that ignore termination and close inherited pipes.
21
+
10
22
  ## 0.18.12
11
23
 
12
24
  - Make optional updates and support discoverable to agents after successful CLI
package/README.md CHANGED
@@ -18,7 +18,7 @@ implementation, and risk level. If those facts drift, the operation stops. It
18
18
  does not silently fall back to general browser control.
19
19
 
20
20
  Bring the model, planner, and tool loop you prefer. Ghostget supplies precise web
21
- capabilities with local custody and explicit evidence. Its standalone macOS menu-bar companion browses local output files and opens CLI guidance. Account, approval, and interface administration are not exposed in this companion. See the [menu-bar companion guide](docs/menubar-release.md).
21
+ capabilities with local custody and explicit evidence. Its menu-bar companion reviews accounts, pending approvals, and operation permissions through the shared desktop-foundation runner, and browses local output files with CLI guidance. See the [menu-bar companion guide](docs/menubar-release.md).
22
22
 
23
23
  The separate public web gateway admits exact HTTPS retrieval URLs under human
24
24
  domain and path rules. Use `ghostget web request <url>` with an authorized local control client running, and
@@ -48,6 +48,10 @@ ghostget plugin list
48
48
 
49
49
  ## Optional updates and support
50
50
 
51
+ The website footer and menu companion offer the same optional support. The menu
52
+ also links to free Ghostget product updates. Select a link to open Hraness
53
+ Accounts, then review and confirm signup or payment in the browser.
54
+
51
55
  Ghostget stays free to use. After useful standalone work, the CLI may write a
52
56
  compact discovery notice to stderr, including when stdout is JSON or piped.
53
57
  It preserves the result on stdout and does not claim an invitation or inspect
@@ -75,7 +79,7 @@ The protocol and [Agent Skill](skills/ghostget/SKILL.md) describe the handoff.
75
79
 
76
80
  ## Built-in provider catalog
77
81
 
78
- This v0.18.12 source tree supports executable actions for 20 services: Beeper,
82
+ This v0.18.13 source tree supports executable actions for 20 services: Beeper,
79
83
  Bluesky, ClasificadosOnline, Facebook, Facebook Groups, Facebook Marketplace,
80
84
  GitHub, Gmail, Hacker News, Instagram, iMessage, LinkedIn, Reddit, Substack,
81
85
  Threads, TikTok, Twitch, WhatsApp, X, and YouTube.
@@ -151,9 +155,9 @@ The optional npm mirror can follow later without delaying canonical delivery.
151
155
  Install the single Ghostget Agent Skill with either runner:
152
156
 
153
157
  ```sh
154
- npx skills add hraness/ghostget#v0.18.12
158
+ npx skills add hraness/ghostget#v0.18.13
155
159
  # or
156
- bunx skills add hraness/ghostget#v0.18.12
160
+ bunx skills add hraness/ghostget#v0.18.13
157
161
  ```
158
162
 
159
163
  The skill teaches Codex, Claude Code, Cursor, and other compatible coding
@@ -163,7 +167,7 @@ install the CLI if it is missing. Start a new agent session after installation.
163
167
  After the matching immutable Release exists, install this exact canonical archive:
164
168
 
165
169
  ```sh
166
- bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.12/hraness-ghostget-0.18.12.tgz
170
+ bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.13/hraness-ghostget-0.18.13.tgz
167
171
  ghostget adapter sync-bundled --json
168
172
  ghostget doctor
169
173
  ```
@@ -191,7 +195,7 @@ For that same released coordinate, install Ghostget in an agent or application
191
195
  that owns its own model, planning, tool loop, approvals, and interface:
192
196
 
193
197
  ```sh
194
- bun add https://github.com/hraness/ghostget/releases/download/v0.18.12/hraness-ghostget-0.18.12.tgz
198
+ bun add https://github.com/hraness/ghostget/releases/download/v0.18.13/hraness-ghostget-0.18.13.tgz
195
199
  ```
196
200
 
197
201
  ```ts
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
3
  GHOSTGET_VERSION
4
- } from "./index-9ayntqrw.js";
4
+ } from "./index-maa9hb5z.js";
5
5
  import {
6
6
  canonicalJson,
7
7
  sha256
@@ -4,7 +4,7 @@ import {
4
4
  } from "./index-26yq8q16.js";
5
5
  import {
6
6
  GHOSTGET_VERSION
7
- } from "./index-9ayntqrw.js";
7
+ } from "./index-maa9hb5z.js";
8
8
  import {
9
9
  canonicalJson,
10
10
  sha256
@@ -1,5 +1,5 @@
1
1
  // @bun
2
2
  // src/version.ts
3
- var GHOSTGET_VERSION = "0.18.12";
3
+ var GHOSTGET_VERSION = "0.18.13";
4
4
 
5
5
  export { GHOSTGET_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/ghostget",
3
- "version": "0.18.12",
3
+ "version": "0.18.13",
4
4
  "description": "Open-source CLI and TypeScript SDK for precise web capabilities for AI agents: page capture, verified media archives, encrypted reads, and typed provider operations.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -275,6 +275,7 @@
275
275
  "src/cli.ts",
276
276
  "src/cli-intro.ts",
277
277
  "src/support.ts",
278
+ "src/support-profile.ts",
278
279
  "src/cursor-token.ts",
279
280
  "src/derive-command-helper.ts",
280
281
  "src/derivation-dnr-readiness.ts",
@@ -584,6 +585,7 @@
584
585
  "src/control/credential-helper.ts",
585
586
  "src/control/account-revision.ts",
586
587
  "src/control/interface-cli.ts",
588
+ "src/control/menubar-cli.ts",
587
589
  "src/control/bundled-interfaces.ts"
588
590
  ],
589
591
  "scripts": {
@@ -615,9 +617,7 @@
615
617
  "kb:check": "bunx --bun github:hraness/kb#v0.15.2 check --root kb && bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo .",
616
618
  "kb:catalog": "bunx --bun github:hraness/kb#v0.15.2 catalog --root kb",
617
619
  "prepack": "bun run check",
618
- "check:effect": "bun scripts/check-effect-architecture.ts",
619
- "menubar:build": "bun scripts/build-menubar.ts",
620
- "menubar:check": "bun scripts/check-menubar.ts"
620
+ "check:effect": "bun scripts/check-effect-architecture.ts"
621
621
  },
622
622
  "dependencies": {
623
623
  "@hraness/kb": "https://github.com/hraness/kb/releases/download/v0.19.6/hraness-kb-0.19.6.tgz",
@@ -628,11 +628,12 @@
628
628
  "source-map": "0.6.1",
629
629
  "source-map-support": "0.5.21",
630
630
  "typescript": "6.0.3",
631
- "@1password/sdk": "0.5.0"
631
+ "@1password/sdk": "0.5.0",
632
+ "@hraness/desktop-foundation": "https://github.com/hraness/desktop-foundation/releases/download/v0.6.0/hraness-desktop-foundation-0.6.0.tgz"
632
633
  },
633
634
  "devDependencies": {
634
635
  "@hraness/design-kit": "github:hraness/design-kit#v0.8.0",
635
- "@hraness/site-footer": "github:hraness/site-footer#v0.11.2",
636
+ "@hraness/site-footer": "github:hraness/site-footer#v0.12.2",
636
637
  "@hraness/ui": "github:hraness/ui#v0.5.13",
637
638
  "@steipete/sweet-cookie": "0.4.3",
638
639
  "@types/bun": "^1.3.14",
@@ -19,7 +19,7 @@ description: >-
19
19
 
20
20
  # Ghostget
21
21
 
22
- Ghostget supplies bounded CLI and SDK capabilities with a menu-bar companion for local outputs and CLI guidance. Use it from the caller's own agent loop; Ghostget does not run a model.
22
+ Ghostget supplies bounded CLI and SDK capabilities with a menu-bar companion for account, approval, and permission review plus local outputs and CLI guidance. Use it from the caller's own agent loop; Ghostget does not run a model.
23
23
 
24
24
  ## Install or verify Ghostget
25
25
 
@@ -17,34 +17,34 @@ If Bun is missing, stop and direct the user to the official
17
17
  [Bun installation guide](https://bun.sh/docs/installation). Do not switch
18
18
  package managers or pipe an unreviewed installer into a shell.
19
19
 
20
- This reference is authored for the exact v0.18.12 release coordinate. Use it
20
+ This reference is authored for the exact v0.18.13 release coordinate. Use it
21
21
  only from the matching release-bound Agent Skill after its canonical archive and
22
22
  immutable GitHub Release exist. If the coordinate is not public, stop instead
23
23
  of substituting `main`, another tag, or a different package version. Install
24
24
  that exact release and its reviewed bundled adapter manifests:
25
25
 
26
26
  ```sh
27
- bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.12/hraness-ghostget-0.18.12.tgz
27
+ bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.13/hraness-ghostget-0.18.13.tgz
28
28
  ghostget adapter sync-bundled --json
29
29
  ghostget --help
30
30
  ghostget doctor --json
31
31
  ```
32
32
 
33
- The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.12` is an optional npm
33
+ The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.13` is an optional npm
34
34
  mirror only after verified registry publication. Canonical installation does not
35
35
  wait for registry publication.
36
36
 
37
37
  When upgrading from Wrench, use `ghostget` for new commands and
38
38
  `GHOSTGET_STATE_HOME` for an explicit state root. Existing state is selected in
39
39
  place; do not rename, copy, or delete a state directory as part of the upgrade.
40
- The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.12/docs/ghostget-migration.md)
40
+ The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.13/docs/ghostget-migration.md)
41
41
  explains the retained state aliases and durable protocol names.
42
42
 
43
43
  Do not clone the repository merely to run the CLI. Importing the SDK is a
44
44
  separate project dependency and does not install a global command:
45
45
 
46
46
  ```sh
47
- bun add https://github.com/hraness/ghostget/releases/download/v0.18.12/hraness-ghostget-0.18.12.tgz
47
+ bun add https://github.com/hraness/ghostget/releases/download/v0.18.13/hraness-ghostget-0.18.13.tgz
48
48
  ```
49
49
 
50
50
  `ghostget adapter sync-bundled` upgrades exact bundled baselines, including an
@@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{
96
96
  implementation: Readonly<{
97
97
  producer: Readonly<{
98
98
  package: "@hraness/ghostget";
99
- version: "0.18.12";
99
+ version: "0.18.13";
100
100
  }>;
101
101
  officialCli: Readonly<{
102
102
  implementation: "github.com/beeper/cli";
@@ -0,0 +1,568 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstatSync, readdirSync, type BigIntStats } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { handleCompanionCommand, openBrowser, type CompanionOptions, type MenuItem } from "@hraness/desktop-foundation";
6
+ import { createSupportOffer } from "@hraness/support-foundation";
7
+ import { ghostgetSupportProfile } from "../support-profile";
8
+ import { ghostgetStateHome } from "../storage";
9
+ import { CONTROL_PROTOCOL, type ActivityRow, type ApprovalView, type CapabilityView, type ControlRequest, type ControlResponse, type ControlSnapshot } from "./protocol";
10
+ import type { ControlEnvironment } from "./web-policy";
11
+
12
+ const WEBSITE = "https://ghostget.com/getting-started";
13
+ const SUPPORT_ACTIONS = createSupportOffer(ghostgetSupportProfile, "desktop").actions;
14
+ const MAX_FRAME = 4_194_304;
15
+ const HELPER_TIMEOUT_MS = 15_000;
16
+ const OUTPUTS_LIMIT = 12;
17
+ const OUTPUTS_SCAN_BOUND = 512;
18
+ const OPENABLE_EXTENSIONS = new Set(["pdf", "txt", "md", "csv", "json", "png", "jpg", "jpeg", "gif", "webp", "tiff"]);
19
+ const BROWSERS = [
20
+ { key: "safari", label: "Safari", browser: "safari", profile: null },
21
+ { key: "chrome-default", label: "Chrome · Default", browser: "chrome", profile: "Default" },
22
+ { key: "chrome-profile-1", label: "Chrome · Profile 1", browser: "chrome", profile: "Profile 1" },
23
+ { key: "chrome-profile-2", label: "Chrome · Profile 2", browser: "chrome", profile: "Profile 2" },
24
+ ] as const;
25
+ type Output = { readonly stdout: (text: string) => unknown; readonly stderr: (text: string) => unknown };
26
+
27
+ /** Presentation never lets control data add lines, bidi overrides or unbounded menus. */
28
+ export function menuLabel(text: string, limit = 72): string {
29
+ const clean = [...text]
30
+ .map((character) => (/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(character) ? " " : character))
31
+ .join("").split(/\s+/u).filter((part) => part.length > 0).join(" ");
32
+ const scalars = [...clean];
33
+ return scalars.length > limit ? `${scalars.slice(0, Math.max(0, limit - 1)).join("")}…` : clean;
34
+ }
35
+
36
+ function detailItems(detail: string): MenuItem[] {
37
+ const words = menuLabel(detail, 216).split(" ");
38
+ const rows: string[] = [];
39
+ let current = "";
40
+ for (const word of words) {
41
+ const next = current ? `${current} ${word}` : word;
42
+ if ([...next].length <= 72) current = next;
43
+ else { if (current !== "") rows.push(current); current = word; }
44
+ }
45
+ if (current !== "") rows.push(current);
46
+ const bounded = rows.slice(0, 3);
47
+ return bounded.length > 0 ? bounded.map((line) => ({ kind: "label" as const, label: menuLabel(line, 72) })) : [{ kind: "label" as const, label: "No additional detail." }];
48
+ }
49
+
50
+ /** One output file that passed ownership, type and permission checks. The
51
+ * identity fields revalidate the same file at dispatch time. */
52
+ export interface OutputEntry {
53
+ readonly name: string;
54
+ readonly size: number;
55
+ readonly modifiedMs: number;
56
+ readonly identity: { readonly dev: bigint; readonly ino: bigint; readonly size: bigint; readonly mtimeNs: bigint; readonly mode: bigint };
57
+ }
58
+ export interface OutputsView {
59
+ readonly directory: { readonly dev: bigint; readonly ino: bigint } | null;
60
+ readonly entries: readonly OutputEntry[];
61
+ readonly message: string | null;
62
+ readonly truncated: boolean;
63
+ }
64
+ const EMPTY_OUTPUTS: OutputsView = { directory: null, entries: [], message: null, truncated: false };
65
+ const UNAVAILABLE_OUTPUTS: OutputsView = { directory: null, entries: [], message: "Outputs unavailable", truncated: false };
66
+
67
+ /** Bounded newest-first listing of the product outputs directory. Reads never
68
+ * create the directory and never follow symlinks; entries must be regular
69
+ * files owned by the current user without group or other write bits. */
70
+ export function readOutputs(directory: string): OutputsView {
71
+ const uid = process.getuid?.();
72
+ const dirInfo = (() => { try { return lstatSync(directory, { bigint: true }); } catch { return null; } })();
73
+ if (dirInfo === null || uid === undefined || !dirInfo.isDirectory() || dirInfo.isSymbolicLink() || dirInfo.uid !== BigInt(uid) || (dirInfo.mode & 0o777n) !== 0o700n) return UNAVAILABLE_OUTPUTS;
74
+ const entry = (name: string): OutputEntry | null => {
75
+ if (name.startsWith(".") || name.includes("/") || name.includes("\u0000")) return null;
76
+ let info: BigIntStats;
77
+ try { info = lstatSync(join(directory, name), { bigint: true }); } catch { return null; }
78
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== BigInt(uid) || (info.mode & 0o022n) !== 0n) return null;
79
+ return { name, size: Number(info.size), modifiedMs: Number(info.mtimeMs), identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeNs: info.mtimeNs, mode: info.mode } };
80
+ };
81
+ let names: readonly string[];
82
+ try { names = readdirSync(directory).slice(0, OUTPUTS_SCAN_BOUND); } catch { return UNAVAILABLE_OUTPUTS; }
83
+ const entries = names.flatMap((name) => { const found = entry(name); return found === null ? [] : [found]; });
84
+ const truncated = names.length === OUTPUTS_SCAN_BOUND || entries.length > OUTPUTS_LIMIT;
85
+ entries.sort((a, b) => b.identity.mtimeNs === a.identity.mtimeNs ? a.name.localeCompare(b.name) : b.identity.mtimeNs > a.identity.mtimeNs ? 1 : -1);
86
+ return { directory: { dev: dirInfo.dev, ino: dirInfo.ino }, entries: entries.slice(0, OUTPUTS_LIMIT), message: entries.length === 0 ? "No output files" : null, truncated };
87
+ }
88
+
89
+ /** Wire action ids carry only entry indices; file names never enter the wire
90
+ * contract, and dispatch revalidates identity against the stored listing. */
91
+
92
+ /** Revalidate directory and entry identity at dispatch. An OS open remains a
93
+ * handoff; this refuses stale or swapped targets. */
94
+ function validatedOutputPath(directory: string, expected: OutputsView, name: string): string | null {
95
+ const uid = process.getuid?.();
96
+ if (expected.directory === null || uid === undefined) return null;
97
+ const dirInfo = (() => { try { return lstatSync(directory, { bigint: true }); } catch { return null; } })();
98
+ if (dirInfo === null || !dirInfo.isDirectory() || dirInfo.isSymbolicLink() || dirInfo.dev !== expected.directory.dev || dirInfo.ino !== expected.directory.ino) return null;
99
+ const expectedEntry = expected.entries.find((item) => item.name === name);
100
+ if (expectedEntry === undefined) return null;
101
+ const fresh = (() => { try { return lstatSync(join(directory, name), { bigint: true }); } catch { return null; } })();
102
+ if (fresh === null || !fresh.isFile() || fresh.isSymbolicLink() || fresh.uid !== BigInt(uid) || (fresh.mode & 0o022n) !== 0n
103
+ || fresh.dev !== expectedEntry.identity.dev || fresh.ino !== expectedEntry.identity.ino
104
+ || fresh.size !== expectedEntry.identity.size || fresh.mtimeNs !== expectedEntry.identity.mtimeNs || fresh.mode !== expectedEntry.identity.mode) return null;
105
+ return join(directory, name);
106
+ }
107
+
108
+ function outputSize(size: number): string {
109
+ if (size < 1024) return `${size} B`;
110
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
111
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
112
+ }
113
+ function outputItems(outputs: OutputsView): MenuItem[] {
114
+ const items: MenuItem[] = [];
115
+ if (outputs.message !== null) items.push({ kind: "label", label: menuLabel(outputs.message) });
116
+ outputs.entries.forEach((entry, index) => {
117
+ const detail: MenuItem[] = [{ kind: "label", label: menuLabel(`${outputSize(entry.size)} · ${new Date(entry.modifiedMs).toLocaleString()}`) }];
118
+ const extension = entry.name.includes(".") ? entry.name.slice(entry.name.lastIndexOf(".") + 1).toLowerCase() : "";
119
+ if (OPENABLE_EXTENSIONS.has(extension)) detail.push({ kind: "action", id: `output:open:${index}`, label: "Open file" });
120
+ detail.push({ kind: "action", id: `output:reveal:${index}`, label: "Reveal in file manager" });
121
+ detail.push({ kind: "action", id: `output:copy:${index}`, label: "Copy file path" });
122
+ items.push({ kind: "submenu", label: menuLabel(entry.name), items: detail });
123
+ });
124
+ if (outputs.truncated) items.push({ kind: "label", label: `Showing up to ${OUTPUTS_LIMIT} files from a bounded scan` });
125
+ if (outputs.directory !== null && outputs.entries.length > 0) items.push({ kind: "action", id: "output:folder", label: "Reveal outputs folder" });
126
+ if (items.length === 0) items.push({ kind: "label", label: "No output files" });
127
+ return items;
128
+ }
129
+
130
+ const CLI_COMMANDS = ["ghostget --help", "ghostget menubar --help", "ghostget menubar status"] as const;
131
+ function cliHelpItems(): MenuItem[] {
132
+ return [
133
+ { kind: "action", id: "clip:0", label: "CLI help" },
134
+ { kind: "action", id: "clip:1", label: "Menu-bar help" },
135
+ { kind: "action", id: "clip:2", label: "Menu-bar installation status" },
136
+ ];
137
+ }
138
+
139
+ /** OS open/reveal handoff for one validated path. Explorer exit codes are not
140
+ * meaningful, so only spawn failure degrades the menu. */
141
+ function openPath(path: string, reveal: boolean): void {
142
+ const command = process.platform === "darwin"
143
+ ? (reveal ? ["open", "-R", path] : ["open", path])
144
+ : process.platform === "win32"
145
+ ? (reveal ? ["explorer", `/select,${path}`] : ["explorer", path])
146
+ : (reveal ? ["xdg-open", dirname(path)] : ["xdg-open", path]);
147
+ try { const child = Bun.spawnSync(command, { stdout: "ignore", stderr: "ignore" }); if (process.platform !== "win32" && child.exitCode !== 0) throw new Error("exit"); }
148
+ catch { throw new Error("ghostget-open-unavailable"); }
149
+ }
150
+ function copyText(text: string): void {
151
+ const command = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : ["xclip", "-selection", "clipboard"];
152
+ try { const child = Bun.spawnSync(command, { stdin: Buffer.from(text, "utf8"), stdout: "ignore", stderr: "ignore" }); if (child.exitCode !== 0) throw new Error("exit"); }
153
+ catch { throw new Error("ghostget-clipboard-unavailable"); }
154
+ }
155
+
156
+ /** Bounded administrative client over the helper's private stdio channel. The
157
+ * menu companion owns the helper process; agent requests stay on the socket. */
158
+ interface HelperClient { request(request: ControlRequest, timeoutMs?: number): Promise<ControlResponse>; close(): void }
159
+ function spawnHelper(environment: ControlEnvironment): HelperClient {
160
+ const script = fileURLToPath(new URL("./helper.ts", import.meta.url));
161
+ const env: Record<string, string> = { ...process.env } as Record<string, string>;
162
+ for (const [key, value] of Object.entries(environment)) if (value !== undefined) env[key] = value;
163
+ const child = Bun.spawn([process.execPath, "--no-env-file", "--no-install", script], { cwd: dirname(script), env, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
164
+ const pending = new Map<string, { resolve: (response: ControlResponse) => void; timer: ReturnType<typeof setTimeout> }>();
165
+ let buffer = Buffer.alloc(0);
166
+ let closed = false;
167
+ void new Response(child.stderr).text();
168
+ const settle = (response: ControlResponse | null): void => {
169
+ const slots = [...pending.values()]; pending.clear();
170
+ for (const slot of slots) {
171
+ clearTimeout(slot.timer);
172
+ slot.resolve(response ?? { ok: false, code: "CONTROL_DISCONNECTED", message: "The Ghostget control helper disconnected." });
173
+ }
174
+ };
175
+ const read = (async () => {
176
+ const reader = child.stdout.getReader();
177
+ try {
178
+ for (;;) {
179
+ const next = await reader.read(); if (next.done) break;
180
+ buffer = Buffer.concat([buffer, next.value]);
181
+ if (buffer.length > MAX_FRAME) throw new Error("oversized helper response");
182
+ let end: number;
183
+ while ((end = buffer.indexOf(10)) >= 0) {
184
+ const line = buffer.subarray(0, end); buffer = buffer.subarray(end + 1);
185
+ let frame: Record<string, unknown>;
186
+ try { frame = JSON.parse(line.toString("utf8")) as Record<string, unknown>; } catch { continue; }
187
+ const id = typeof frame.id === "string" ? frame.id : null;
188
+ if (id === null || frame.protocol !== CONTROL_PROTOCOL) continue;
189
+ const slot = pending.get(id); if (slot === undefined) continue;
190
+ pending.delete(id); clearTimeout(slot.timer);
191
+ slot.resolve(frame as unknown as ControlResponse);
192
+ }
193
+ }
194
+ } catch { /* a dead or oversized channel degrades the menu, it never retries */ }
195
+ finally { closed = true; settle(null); }
196
+ })();
197
+ void read;
198
+ void child.exited.then(() => { closed = true; settle(null); });
199
+ return {
200
+ request: (request, timeoutMs = HELPER_TIMEOUT_MS) => new Promise<ControlResponse>((resolve) => {
201
+ if (closed) { resolve({ ok: false, code: "CONTROL_DISCONNECTED", message: "The Ghostget control helper is not running." }); return; }
202
+ const id = randomUUID();
203
+ const timer = setTimeout(() => { pending.delete(id); resolve({ ok: false, code: "CONTROL_TIMEOUT", message: "The Ghostget control helper did not answer in time." }); }, timeoutMs);
204
+ pending.set(id, { resolve, timer });
205
+ try { child.stdin.write(`${JSON.stringify({ id, protocol: CONTROL_PROTOCOL, request })}\n`); }
206
+ catch { pending.delete(id); clearTimeout(timer); resolve({ ok: false, code: "CONTROL_DISCONNECTED", message: "The Ghostget control helper disconnected." }); }
207
+ }),
208
+ close: () => { try { child.stdin.end(); } catch { /* already closed */ } },
209
+ };
210
+ }
211
+
212
+ function accountItems(snapshot: ControlSnapshot, enabled: boolean): MenuItem[] {
213
+ const accounts = snapshot.accounts;
214
+ const rows: MenuItem[] = accounts.slice(0, 20).map((account) => {
215
+ const status = account.status === "verified" ? "Verified" : account.status === "configured" ? "Configured" : "Reconnect required";
216
+ const items: MenuItem[] = [
217
+ { kind: "label", label: status },
218
+ ...detailItems([account.provider, account.kind, account.subject].filter((part): part is string => part !== null).join(" · ") || "No subject recorded"),
219
+ ];
220
+ if (enabled && account.status === "reconnect-required") {
221
+ items.push({
222
+ kind: "submenu",
223
+ label: "Reconnect",
224
+ items: snapshot.connectionProviders.map((provider) => ({
225
+ kind: "submenu" as const,
226
+ label: menuLabel(provider.title) || "Provider",
227
+ items: BROWSERS.map((choice) => ({ kind: "action" as const, id: `reconnect:${account.id}:${provider.id}:${choice.key}`, label: choice.label })),
228
+ })),
229
+ });
230
+ }
231
+ if (enabled) items.push({ kind: "action", id: `disconnect:${account.id}`, label: "Disconnect this account" });
232
+ return { kind: "submenu" as const, label: menuLabel(`${account.id} · ${status}`) || "Account", items };
233
+ });
234
+ if (accounts.length === 0) rows.push({ kind: "label", label: "No accounts connected" });
235
+ if (accounts.length > 20) rows.push({ kind: "label", label: `${accounts.length - 20} more accounts` });
236
+ return rows;
237
+ }
238
+
239
+ function approvalItems(approvals: readonly ApprovalView[], enabled: boolean): MenuItem[] {
240
+ const rows: MenuItem[] = approvals.slice(0, 10).map((approval) => ({
241
+ kind: "submenu" as const,
242
+ label: menuLabel(approval.title) || "Approval request",
243
+ items: [
244
+ ...detailItems([approval.kind, approval.account ?? "no account", approval.effect, approval.preview].join(" · ")),
245
+ { kind: "label", label: menuLabel(`Expires ${approval.expiresAt}`) },
246
+ { kind: "action", id: `approval:allow:${approval.id}`, label: "Allow once", enabled },
247
+ { kind: "action", id: `approval:deny:${approval.id}`, label: "Deny", enabled },
248
+ ],
249
+ }));
250
+ if (approvals.length === 0) rows.push({ kind: "label", label: "No pending approvals" });
251
+ if (approvals.length > 10) rows.push({ kind: "label", label: `${approvals.length - 10} more approvals` });
252
+ return rows;
253
+ }
254
+
255
+ export interface Attempt { readonly attemptId: string; readonly title: string; status: "awaiting-sign-in" | "verified"; subject: string | null }
256
+ function connectItems(snapshot: ControlSnapshot, attempts: ReadonlyMap<string, Attempt>, enabled: boolean): MenuItem[] {
257
+ const rows: MenuItem[] = [];
258
+ for (const attempt of [...attempts.values()].slice(0, 8)) {
259
+ const items: MenuItem[] = attempt.status === "verified"
260
+ ? [
261
+ { kind: "label", label: menuLabel(`Signed in as ${attempt.subject ?? "unknown"}`) },
262
+ { kind: "action", id: `attempt:commit:${attempt.attemptId}`, label: `Connect ${menuLabel(attempt.subject ?? "this account", 40)}`, enabled },
263
+ ]
264
+ : [
265
+ { kind: "label", label: "Finish sign-in in the opened browser, then verify." },
266
+ { kind: "action", id: `attempt:verify:${attempt.attemptId}`, label: "Verify sign-in", enabled },
267
+ ];
268
+ items.push({ kind: "action", id: `attempt:cancel:${attempt.attemptId}`, label: "Cancel", enabled });
269
+ rows.push({ kind: "submenu", label: menuLabel(`${attempt.title} · ${attempt.status === "verified" ? "verified" : "awaiting sign-in"}`), items });
270
+ }
271
+ for (const provider of snapshot.connectionProviders.slice(0, 12)) {
272
+ rows.push({
273
+ kind: "submenu",
274
+ label: menuLabel(provider.title) || "Provider",
275
+ items: BROWSERS.map((choice) => ({ kind: "action" as const, id: `connect:${provider.id}:${choice.key}`, label: choice.label, enabled })),
276
+ });
277
+ }
278
+ if (rows.length === 0) rows.push({ kind: "label", label: "No connection providers" });
279
+ return rows;
280
+ }
281
+
282
+ function capabilityItems(capabilities: readonly CapabilityView[]): MenuItem[] {
283
+ if (capabilities.length === 0) return [{ kind: "label", label: "No capabilities discovered" }];
284
+ const counts = new Map<string, number>();
285
+ for (const capability of capabilities) counts.set(capability.state, (counts.get(capability.state) ?? 0) + 1);
286
+ const rows: MenuItem[] = [...counts.entries()].map(([state, count]) => ({ kind: "label" as const, label: `${count} ${state}` }));
287
+ for (const capability of capabilities.slice(0, 15)) {
288
+ rows.push({ kind: "label", label: menuLabel(`${capability.adapterId} · ${capability.operationId} · ${capability.permission}`) });
289
+ }
290
+ if (capabilities.length > 15) rows.push({ kind: "label", label: `${capabilities.length - 15} more capabilities` });
291
+ return rows;
292
+ }
293
+
294
+ function permissionItems(snapshot: ControlSnapshot, enabled: boolean): MenuItem[] {
295
+ const rows: MenuItem[] = [];
296
+ if (!snapshot.policy.managed) rows.push({ kind: "action", id: "permission:enable", label: "Enable operation permissions", enabled });
297
+ else rows.push({ kind: "label", label: "Operation permissions are managed per account." });
298
+ const unmanaged = snapshot.capabilities.filter((capability) => capability.permission === "unmanaged");
299
+ for (const capability of unmanaged.slice(0, 15)) {
300
+ rows.push({
301
+ kind: "submenu",
302
+ label: menuLabel(`${capability.adapterId} · ${capability.operationId}`),
303
+ items: [
304
+ ...detailItems(`${capability.surface} · ${capability.effect} · risk ${capability.risk}`),
305
+ { kind: "action", id: `permission:allow:${capability.adapterId}:${capability.operationId}`, label: "Allow", enabled },
306
+ { kind: "action", id: `permission:ask:${capability.adapterId}:${capability.operationId}`, label: "Ask each time", enabled },
307
+ { kind: "action", id: `permission:deny:${capability.adapterId}:${capability.operationId}`, label: "Deny", enabled },
308
+ ],
309
+ });
310
+ }
311
+ if (unmanaged.length > 15) rows.push({ kind: "label", label: `${unmanaged.length - 15} more operations need review` });
312
+ if (!snapshot.policy.managed && unmanaged.length === 0) rows.push({ kind: "label", label: "All operations already have a decision." });
313
+ return rows;
314
+ }
315
+
316
+ function webItems(snapshot: ControlSnapshot): MenuItem[] {
317
+ const rules = snapshot.web.rules;
318
+ const rows: MenuItem[] = [{ kind: "label", label: snapshot.web.gatewayOnly ? "Gateway-only mode" : "Direct retrieval allowed" }];
319
+ for (const rule of rules.slice(0, 10)) {
320
+ rows.push({ kind: "label", label: menuLabel(`${rule.origin}${rule.path.value} · ${rule.decision}`) });
321
+ }
322
+ if (rules.length > 10) rows.push({ kind: "label", label: `${rules.length - 10} more rules` });
323
+ return rows;
324
+ }
325
+
326
+ function interfaceItems(snapshot: ControlSnapshot): MenuItem[] {
327
+ const interfaces = snapshot.interfaces;
328
+ if (interfaces.length === 0) return [{ kind: "label", label: "No interfaces installed" }];
329
+ const rows: MenuItem[] = interfaces.slice(0, 10).map((entry) => ({
330
+ kind: "submenu" as const,
331
+ label: menuLabel(`${entry.title} · ${entry.state}`),
332
+ items: detailItems(`${entry.operationCount} operations · ${entry.adapterIds.join(", ") || "no adapters"}${entry.issues.length > 0 ? ` · ${entry.issues.length} issue(s)` : ""}`),
333
+ }));
334
+ if (interfaces.length > 10) rows.push({ kind: "label", label: `${interfaces.length - 10} more interfaces` });
335
+ return rows;
336
+ }
337
+
338
+ function activityItems(rows: readonly ActivityRow[]): MenuItem[] {
339
+ if (rows.length === 0) return [{ kind: "label", label: "No recent gateway activity" }];
340
+ return rows.slice(0, 8).map((row) => ({
341
+ kind: "label" as const,
342
+ label: menuLabel(`${row.method} ${row.origin ?? row.endpoint ?? "request"} · ${row.outcome}${row.httpStatus === null ? "" : ` · ${row.httpStatus}`}`),
343
+ }));
344
+ }
345
+
346
+ /** Map one control snapshot onto the shared menu contract. The helper stays
347
+ * the authority; rows are display-only except the bounded actions below. */
348
+ export function snapshotItems(
349
+ snapshot: ControlSnapshot | null,
350
+ attempts: ReadonlyMap<string, Attempt>,
351
+ status: { confirmedAgeSeconds: number | null; fresh: boolean; detail?: string | undefined },
352
+ activity: readonly ActivityRow[] = [],
353
+ outputs: OutputsView = EMPTY_OUTPUTS,
354
+ notice: string | null = null,
355
+ ): MenuItem[] {
356
+ const confirmed = snapshot !== null;
357
+ const age = status.confirmedAgeSeconds;
358
+ const ageText = age !== null && age < 60 ? `${age}s ago` : `${Math.floor((age ?? 0) / 60)}m ago`;
359
+ const updated = age === null ? "Control status not confirmed" : status.fresh ? `Updated ${ageText}` : `Last confirmed ${ageText}`;
360
+ const tail: MenuItem[] = [
361
+ { kind: "separator" },
362
+ { kind: "submenu", label: `Outputs · ${outputs.entries.length}`, items: outputItems(outputs) },
363
+ ...(notice === null ? [] : [{ kind: "label" as const, label: menuLabel(notice) }]),
364
+ { kind: "label", label: menuLabel(updated) },
365
+ { kind: "action", id: "refresh", label: "Refresh status" },
366
+ { kind: "action", id: "open-website", label: "Open Ghostget…" },
367
+ { kind: "action", id: "support:updates", label: "Get Ghostget updates (free)…" },
368
+ { kind: "action", id: "support:paid", label: "Support Ghostget development (optional paid)…" },
369
+ { kind: "submenu", label: "Copy CLI command", items: cliHelpItems() },
370
+ { kind: "separator" },
371
+ { kind: "quit", label: "Quit Ghostget" },
372
+ ];
373
+ if (!confirmed) {
374
+ return [
375
+ { kind: "label", label: "Ghostget control unavailable" },
376
+ { kind: "label", label: menuLabel(status.detail ?? "The control helper is not running. Requests need the companion open.") },
377
+ ...tail,
378
+ ];
379
+ }
380
+ const pending = snapshot.approvals.length;
381
+ const reconnect = snapshot.accounts.filter((account) => account.status === "reconnect-required").length;
382
+ return [
383
+ { kind: "label", label: menuLabel(`Ghostget ${snapshot.version}`) },
384
+ { kind: "label", label: menuLabel(`${snapshot.accounts.length} account${snapshot.accounts.length === 1 ? "" : "s"}${reconnect > 0 ? ` · ${reconnect} need reconnect` : ""} · ${pending} pending approval${pending === 1 ? "" : "s"}`) },
385
+ { kind: "separator" },
386
+ { kind: "submenu", label: `Approvals · ${pending}`, items: approvalItems(snapshot.approvals, confirmed) },
387
+ { kind: "submenu", label: `Accounts · ${snapshot.accounts.length}`, items: accountItems(snapshot, confirmed) },
388
+ { kind: "submenu", label: "Connect account", items: connectItems(snapshot, attempts, confirmed) },
389
+ { kind: "submenu", label: "Permissions", items: permissionItems(snapshot, confirmed) },
390
+ { kind: "submenu", label: `Capabilities · ${snapshot.capabilities.length}`, items: capabilityItems(snapshot.capabilities) },
391
+ { kind: "submenu", label: `Web rules · ${snapshot.web.rules.length}`, items: webItems(snapshot) },
392
+ { kind: "submenu", label: `Interfaces · ${snapshot.interfaces.length}`, items: interfaceItems(snapshot) },
393
+ { kind: "submenu", label: "Recent activity", items: activityItems(activity) },
394
+ { kind: "label", label: menuLabel(`Vault · 1Password ${snapshot.vault.available ? "available" : "unavailable"}`) },
395
+ ...tail,
396
+ ];
397
+ }
398
+
399
+ /** The Ghostget menu companion owns the control helper's stdio channel and is
400
+ * a disposable client of it. All reads and mutations use bounded control
401
+ * requests; the shared runner renders state and enforces revision-checked
402
+ * dispatch. Failed or indeterminate mutations are never retried. */
403
+ export function companionOptions(
404
+ environment: ControlEnvironment,
405
+ openAccountPage: (url: string) => Promise<void> = openBrowser,
406
+ ): CompanionOptions {
407
+ let helper: HelperClient | null = null;
408
+ let lastSnapshot: ControlSnapshot | null = null;
409
+ let confirmedAt: number | null = null;
410
+ let lastOutputs: OutputsView = EMPTY_OUTPUTS;
411
+ let notice: string | null = null;
412
+ let openingAccountPage = false;
413
+ const attempts = new Map<string, Attempt>();
414
+ const outputsDirectory = join(ghostgetStateHome(environment), "outputs");
415
+ const client = (): HelperClient => {
416
+ if (helper === null) helper = spawnHelper(environment);
417
+ return helper;
418
+ };
419
+ const drop = (): void => { helper?.close(); helper = null; };
420
+ process.once("exit", drop);
421
+ const request = async (body: ControlRequest, timeoutMs?: number): Promise<ControlResponse> => {
422
+ const response = await client().request(body, timeoutMs);
423
+ if (!response.ok && (response.code === "CONTROL_DISCONNECTED" || response.code === "CONTROL_TIMEOUT")) drop();
424
+ return response;
425
+ };
426
+ return {
427
+ appId: "ghostget",
428
+ name: "Ghostget",
429
+ title: "Gg",
430
+ tooltip: "Ghostget · control, outputs and CLI help",
431
+ stateDir: join(ghostgetStateHome(environment), "menubar"),
432
+ refreshMs: 5_000,
433
+ timeoutMs: 90_000,
434
+ snapshot: async () => {
435
+ let fresh = false;
436
+ const response = await request({ action: "snapshot", accountId: null });
437
+ if (response.ok && response.data.kind === "snapshot") {
438
+ if (lastSnapshot !== null && response.data.snapshot.policy.revision < lastSnapshot.policy.revision) {
439
+ // A late or replayed response must never undo newer owner state.
440
+ } else {
441
+ lastSnapshot = response.data.snapshot;
442
+ confirmedAt = Date.now();
443
+ fresh = true;
444
+ }
445
+ }
446
+ let activity: readonly ActivityRow[] = [];
447
+ if (fresh) {
448
+ const page = await request({ action: "activity.query", query: { search: "", method: "all", outcome: "all", origin: null, since: null, order: "newest", cursor: null, limit: 8 } });
449
+ if (page.ok && page.data.kind === "activity") activity = page.data.page.rows;
450
+ }
451
+ lastOutputs = readOutputs(outputsDirectory);
452
+ return snapshotItems(lastSnapshot, attempts, { confirmedAgeSeconds: confirmedAt === null ? null : Math.max(0, Math.floor((Date.now() - confirmedAt) / 1000)), fresh, detail: response.ok ? undefined : response.message }, activity, lastOutputs, notice);
453
+ },
454
+ onAction: async (id) => {
455
+ if (id === "refresh") { notice = null; return; } // the runner re-reads state after every action
456
+ if (id === "open-website") { await openBrowser(WEBSITE); return; }
457
+ if (id === "support:updates" || id === "support:paid") {
458
+ if (openingAccountPage) return;
459
+ const action = SUPPORT_ACTIONS.find((item) => item.kind === (id === "support:updates" ? "updates" : "support"));
460
+ if (action === undefined) return;
461
+ openingAccountPage = true;
462
+ notice = null;
463
+ try { await openAccountPage(action.url); }
464
+ catch { notice = "Could not open Accounts. Try again from the menu."; }
465
+ finally { openingAccountPage = false; }
466
+ return;
467
+ }
468
+ if (id === "output:folder") {
469
+ const expected = lastOutputs.directory;
470
+ const fresh = (() => { try { return lstatSync(outputsDirectory, { bigint: true }); } catch { return null; } })();
471
+ if (expected !== null && fresh !== null && fresh.isDirectory() && !fresh.isSymbolicLink() && fresh.dev === expected.dev && fresh.ino === expected.ino) openPath(outputsDirectory, true);
472
+ return;
473
+ }
474
+ if (id.startsWith("output:open:") || id.startsWith("output:reveal:") || id.startsWith("output:copy:")) {
475
+ const entry = lastOutputs.entries[Number(id.slice(id.indexOf(":", 7) + 1))];
476
+ const path = entry === undefined ? null : validatedOutputPath(outputsDirectory, lastOutputs, entry.name);
477
+ if (path === null) { notice = "File changed or unavailable; refresh outputs"; return; }
478
+ notice = null;
479
+ if (id.startsWith("output:copy:")) copyText(path);
480
+ else openPath(path, id.startsWith("output:reveal:"));
481
+ return;
482
+ }
483
+ if (id.startsWith("clip:")) { const command = CLI_COMMANDS[Number(id.slice(5))]; if (command !== undefined) copyText(command); return; }
484
+ const snapshot = lastSnapshot;
485
+ if (snapshot === null) return;
486
+ const parts = id.split(":");
487
+ const fail = (response: ControlResponse): void => { if (!response.ok) throw new Error(`ghostget-${response.code}`); };
488
+ if (id === "permission:enable") { fail(await request({ action: "permission.enable", expectedRevision: snapshot.policy.revision })); return; }
489
+ if (parts[0] === "approval" && parts.length === 3 && (parts[1] === "allow" || parts[1] === "deny")) {
490
+ const approval = snapshot.approvals.find((item) => item.id === parts[2]);
491
+ if (approval === undefined) return;
492
+ fail(await request({ action: "approval.decide", id: approval.id, digest: approval.digest, decision: parts[1] === "allow" ? "allow-once" : "deny" }));
493
+ return;
494
+ }
495
+ if (parts[0] === "disconnect" && parts.length === 2) {
496
+ const account = snapshot.accounts.find((item) => item.id === parts[1]);
497
+ if (account === undefined) return;
498
+ fail(await request({ action: "connection.disconnect", id: account.id, expectedRevision: account.revision }));
499
+ return;
500
+ }
501
+ if (parts[0] === "connect" && parts.length === 3) {
502
+ const provider = snapshot.connectionProviders.find((item) => item.id === parts[1]);
503
+ const choice = BROWSERS.find((item) => item.key === parts[2]);
504
+ if (provider === undefined || choice === undefined) return;
505
+ const response = await request({ action: "connection.begin", id: `${provider.id}-${randomUUID().slice(0, 8)}`, provider: provider.id, browser: choice.browser, profile: choice.profile, expectedRevision: null });
506
+ fail(response);
507
+ if (response.ok && response.data.kind === "connection") attempts.set(response.data.attemptId, { attemptId: response.data.attemptId, title: provider.title, status: "awaiting-sign-in", subject: null });
508
+ return;
509
+ }
510
+ if (parts[0] === "reconnect" && parts.length === 4) {
511
+ const account = snapshot.accounts.find((item) => item.id === parts[1]);
512
+ const provider = snapshot.connectionProviders.find((item) => item.id === parts[2]);
513
+ const choice = BROWSERS.find((item) => item.key === parts[3]);
514
+ if (account === undefined || provider === undefined || choice === undefined) return;
515
+ const response = await request({ action: "connection.begin", id: account.id, provider: provider.id, browser: choice.browser, profile: choice.profile, expectedRevision: account.revision });
516
+ fail(response);
517
+ if (response.ok && response.data.kind === "connection") attempts.set(response.data.attemptId, { attemptId: response.data.attemptId, title: provider.title, status: "awaiting-sign-in", subject: null });
518
+ return;
519
+ }
520
+ if (parts[0] === "attempt" && parts.length === 3) {
521
+ const attempt = attempts.get(parts[2]!);
522
+ if (attempt === undefined) return;
523
+ if (parts[1] === "cancel") { await request({ action: "connection.cancel", attemptId: attempt.attemptId }); attempts.delete(attempt.attemptId); return; }
524
+ if (parts[1] === "verify") {
525
+ const response = await request({ action: "connection.verify", attemptId: attempt.attemptId }, 75_000);
526
+ if (!response.ok) { attempts.delete(attempt.attemptId); throw new Error(`ghostget-${response.code}`); }
527
+ if (response.data.kind === "connection" && response.data.status === "verified") attempts.set(attempt.attemptId, { ...attempt, status: "verified", subject: response.data.subject });
528
+ return;
529
+ }
530
+ if (parts[1] === "commit") {
531
+ if (attempt.status !== "verified" || attempt.subject === null) return;
532
+ const response = await request({ action: "connection.commit", attemptId: attempt.attemptId, expectedSubject: attempt.subject });
533
+ attempts.delete(attempt.attemptId);
534
+ fail(response);
535
+ return;
536
+ }
537
+ return;
538
+ }
539
+ if (parts[0] === "permission" && parts.length === 4 && (parts[1] === "allow" || parts[1] === "ask" || parts[1] === "deny")) {
540
+ const capability = snapshot.capabilities.find((item) => item.adapterId === parts[2] && item.operationId === parts[3]);
541
+ if (capability === undefined) return;
542
+ fail(await request({ action: "permission.set", adapterId: capability.adapterId, operationId: capability.operationId, accountId: snapshot.accountId, decision: parts[1], expectedRevision: snapshot.policy.revision, expectedCapabilityDigest: capability.digest }));
543
+ }
544
+ },
545
+ };
546
+ }
547
+
548
+ /** Delegate the product `menubar` command family to the shared lifecycle. */
549
+ export async function runMenubarCommand(args: readonly string[], environment: ControlEnvironment = process.env, output: Output): Promise<number> {
550
+ const rest = args.slice(1);
551
+ if (rest[0] === "--help" || rest[0] === "help" || rest[0] === "-h") {
552
+ output.stdout("Usage: ghostget menubar [start|stop|status|doctor|install|uninstall]\nRuns the shared menu-bar companion; install registers login startup.\n");
553
+ return 0;
554
+ }
555
+ if (rest.length > 1 || (rest[0] !== undefined && !["start", "stop", "status", "doctor", "install", "uninstall", "--foreground", "--background"].includes(rest[0]))) {
556
+ output.stderr("Usage: ghostget menubar [start|stop|status|doctor|install|uninstall]\n");
557
+ return 1;
558
+ }
559
+ const mapped = rest[0] === "--background" ? [] : rest;
560
+ const cli = fileURLToPath(new URL("../cli.ts", import.meta.url));
561
+ const binary = environment.GHOSTGET_MENUBAR;
562
+ return await handleCompanionCommand(companionOptions(environment), {
563
+ args: mapped,
564
+ ...(binary === undefined || binary === "" ? {} : { binary }),
565
+ foreground: { executable: process.execPath, args: [cli, "menubar", "--foreground"] },
566
+ write: (result) => output.stdout(`${JSON.stringify(result)}\n`),
567
+ });
568
+ }
@@ -448,18 +448,38 @@ export async function runImsgRpc(
448
448
  let timedOut = false;
449
449
  let cancelled = false;
450
450
  let forceKill: ReturnType<typeof setTimeout> | null = null;
451
+ let groupTerminated: Promise<boolean> | null = null;
451
452
  let terminationStarted = false;
452
453
  const signalGroup = (signal: "SIGTERM" | "SIGKILL"): void => {
453
454
  try {
454
455
  process.kill(-child.pid, signal);
455
456
  } catch {
456
- // child.exited remains the reaping proof.
457
+ // The bounded group probe below establishes descendant cleanup.
457
458
  }
458
459
  };
459
460
  const terminate = (): void => {
460
461
  if (!terminationStarted) {
461
462
  terminationStarted = true;
462
463
  signalGroup("SIGTERM");
464
+ groupTerminated = (async (): Promise<boolean> => {
465
+ // A joined leader and closed pipes do not prove its descendants exited.
466
+ // Retain the one-second escalation, then bound final group reaping.
467
+ const deadline = performance.now() + 1_500;
468
+ for (;;) {
469
+ try {
470
+ process.kill(-child.pid, 0);
471
+ } catch (error) {
472
+ const code = typeof error === "object" && error !== null
473
+ && "code" in error ? error.code : undefined;
474
+ if (code === "ESRCH") return true;
475
+ // Allow a transient permission denial to settle within the same
476
+ // bound; it is never proof that the group exited.
477
+ if (code !== "EPERM") return false;
478
+ }
479
+ if (performance.now() >= deadline) return false;
480
+ await new Promise<void>((resolve) => setTimeout(resolve, 20));
481
+ }
482
+ })();
463
483
  }
464
484
  forceKill ??= setTimeout(() => signalGroup("SIGKILL"), 1_000);
465
485
  };
@@ -509,8 +529,14 @@ export async function runImsgRpc(
509
529
  });
510
530
  } finally {
511
531
  clearTimeout(timeout);
512
- if (forceKill !== null) clearTimeout(forceKill);
513
532
  invocation.signal?.removeEventListener("abort", onAbort);
533
+ try {
534
+ if (groupTerminated !== null && !await groupTerminated) {
535
+ throw new ImsgCleanupUnverifiedError();
536
+ }
537
+ } finally {
538
+ if (forceKill !== null) clearTimeout(forceKill);
539
+ }
514
540
  }
515
541
  }
516
542
 
@@ -0,0 +1,7 @@
1
+ /** Public product identity shared by the CLI, menu and static website. */
2
+ export const ghostgetSupportProfile = Object.freeze({
3
+ id: "wrench",
4
+ name: "Ghostget",
5
+ valueProposition: "Support ongoing development of precise web tools for agents.",
6
+ updates: true,
7
+ } as const);
package/src/support.ts CHANGED
@@ -2,13 +2,7 @@ import {
2
2
  maybeShowSupportInvitation,
3
3
  runSupportCommand,
4
4
  } from "@hraness/support-foundation/node";
5
-
6
- const profile = {
7
- id: "wrench",
8
- name: "Ghostget",
9
- valueProposition: "Support ongoing development of precise web tools for agents.",
10
- updates: true,
11
- } as const;
5
+ import { ghostgetSupportProfile as profile } from "./support-profile";
12
6
 
13
7
  type SupportOutput = {
14
8
  readonly stdout: (text: string) => unknown;
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Canonical immutable Ghostget package release identity. */
2
- export const GHOSTGET_VERSION = "0.18.12" as const;
2
+ export const GHOSTGET_VERSION = "0.18.13" as const;