gesso-electrobun 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # gesso-electrobun
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fa859bb: A desktop app serves its channels to AI agents. `serveDesktopAgent(channels, options)` in `gesso-electrobun/desktop` serves them over MCP from the main process with `Bun.serve`, which Cottontail provides, on `127.0.0.1:7310` or the next free port after it, refusing any request a web page sends, and reports the URL to connect. `messageBoxConfirm(Utils.showMessageBox)` puts a `@confirm` command to the person with the native dialog, Decline by default. The screen tools are not offered there, since the screen is in each window's render worker.
8
+
9
+ Electrobun bundles the main process with its own build, which takes no plugins, so `gesso-vite-plugin` now ships `gesso-channels`, a command that reads contracts with the same TypeScript 7 checker and writes a module describing them, for the main process to import once; `--check` fails when it is out of date.
10
+
11
+ The Electrobun template uses all of it: the counter is served to agents as the app starts, `hutch run channels` writes `src/shared/channels.described.ts` before every build, `hutch run typecheck` checks it is current, and the contract's JSDoc is written for an agent to read. The template moves to TypeScript 7, clearing the projected config's `baseUrl`, which TypeScript 7 removed, and to Vite 8, with `vite.config.ts` using `import.meta.dirname` and an explicit `.ts` import as Vite 8's config loader asks.
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [5a27b40]
16
+ - Updated dependencies [f265910]
17
+ - Updated dependencies [d36a2fa]
18
+ - Updated dependencies [c38f97e]
19
+ - Updated dependencies [b90ecb2]
20
+ - Updated dependencies [96f4bdc]
21
+ - Updated dependencies [88d93b3]
22
+ - Updated dependencies [dc7f199]
23
+ - Updated dependencies [8fb3607]
24
+ - Updated dependencies [53b4c46]
25
+ - Updated dependencies [f0ade22]
26
+ - Updated dependencies [29a36ac]
27
+ - Updated dependencies [fac08c0]
28
+ - Updated dependencies [8c1b8ed]
29
+ - Updated dependencies [2bfedcd]
30
+ - Updated dependencies [979053a]
31
+ - Updated dependencies [1dfb6c2]
32
+ - Updated dependencies [99538fa]
33
+ - Updated dependencies [fb2a6d8]
34
+ - Updated dependencies [28f5b72]
35
+ - Updated dependencies [b7c9514]
36
+ - Updated dependencies [0bef08b]
37
+ - Updated dependencies [af33f45]
38
+ - Updated dependencies [93d580b]
39
+ - Updated dependencies [68b01e0]
40
+ - Updated dependencies [5d67836]
41
+ - Updated dependencies [acad77f]
42
+ - Updated dependencies [444371c]
43
+ - Updated dependencies [62883e0]
44
+ - Updated dependencies [f02740f]
45
+ - Updated dependencies [cf3b16a]
46
+ - Updated dependencies [5b59d13]
47
+ - gesso-framework@0.5.0
48
+
49
+ ## 0.4.2
50
+
51
+ ### Patch Changes
52
+
53
+ - gesso-framework@0.4.2
54
+
3
55
  ## 0.4.1
4
56
 
5
57
  ### Patch Changes
package/dist/desktop.d.ts CHANGED
@@ -1,6 +1,90 @@
1
1
  import { r as GessoFrame } from "./frames-BrrAFMqE.js";
2
2
  import { ChannelToken, ServedChannel } from "gesso-framework";
3
3
  import { Observable } from "rxjs";
4
+ import { AgentConfirmation, AgentSurface, McpHandlerOptions } from "gesso-framework/agent";
5
+ //#region src/agent.d.ts
6
+ /**
7
+ * A desktop application's channels, served to AI agents over MCP.
8
+ *
9
+ * The main process already holds every channel's source: it is what
10
+ * `createDesktopApp` serves to each window. So it is also where an
11
+ * agent connects. This serves those same sources with MCP's HTTP
12
+ * transport on the person's own machine, through `Bun.serve`, which
13
+ * Cottontail provides as Bun does, and an agent such as Claude Code
14
+ * connects by URL:
15
+ *
16
+ * const agent = serveDesktopAgent([counter], { name: 'my-app', confirm: messageBoxConfirm(Utils.showMessageBox) });
17
+ * // claude mcp add --transport http my-app http://127.0.0.1:7310/mcp
18
+ *
19
+ * The channels are passed rather than read from the app, because
20
+ * `createDesktopApp` takes them per window, and the per-window ones
21
+ * (`windowsChannel`) are about a window an agent does not have.
22
+ *
23
+ * What it offers is the channel tools only. The screen is in each
24
+ * window's render worker, out of the main process's reach, so the
25
+ * screen tools a web app offers are not here.
26
+ *
27
+ * The descriptions come from `channelSchema`. A main process is bundled
28
+ * by Electrobun's own build, which `gesso-vite-plugin` never sees, so
29
+ * the template runs `gesso-channels` to write a module that describes
30
+ * the contracts, and imports it.
31
+ */
32
+ /** A `Bun.serve`, as far as this uses one. */
33
+ type DesktopServe = (options: {
34
+ hostname: string;
35
+ port: number;
36
+ fetch: (request: Request) => Promise<Response>;
37
+ }) => {
38
+ stop(closeActiveConnections?: boolean): void;
39
+ };
40
+ interface DesktopAgentOptions extends Omit<McpHandlerOptions, 'allowedOrigins'> {
41
+ /**
42
+ * The port to listen on (default 7310). When it is taken, the next
43
+ * nine are tried in turn, so a second copy of the app still serves;
44
+ * `url` says which one it got.
45
+ */
46
+ port?: number;
47
+ /** Default `127.0.0.1`: reachable from this machine only. */
48
+ hostname?: string;
49
+ /**
50
+ * Asks the person whether a `@confirm` command may be sent. Without
51
+ * it such a command is refused. `messageBoxConfirm` makes one from
52
+ * Electrobun's native dialog.
53
+ */
54
+ confirm?: (request: AgentConfirmation) => boolean | Promise<boolean>;
55
+ /** The server to start. Defaults to the runtime's `Bun.serve`. */
56
+ serve?: DesktopServe;
57
+ }
58
+ interface DesktopAgent {
59
+ /** Where an agent connects: `http://127.0.0.1:<port>/mcp`. */
60
+ readonly url: string;
61
+ readonly surface: AgentSurface;
62
+ /** Stops serving and stops following the channels. */
63
+ stop(): void;
64
+ }
65
+ declare function serveDesktopAgent(channels: readonly ServedChannel[], options?: DesktopAgentOptions): DesktopAgent;
66
+ /** The options Electrobun's `Utils.showMessageBox` takes, as far as this uses them. */
67
+ type ShowMessageBox = (options: {
68
+ type?: 'info' | 'warning' | 'error' | 'question';
69
+ title?: string;
70
+ message?: string;
71
+ detail?: string;
72
+ buttons?: string[];
73
+ defaultId?: number;
74
+ cancelId?: number;
75
+ }) => Promise<{
76
+ response: number;
77
+ }>;
78
+ /**
79
+ * A `confirm` that asks with the operating system's own dialog.
80
+ *
81
+ * Takes Electrobun's `Utils.showMessageBox` rather than importing it,
82
+ * because Electrobun is a toolchain a project is projected into, not a
83
+ * package this one can depend on. The safe answer is the default one:
84
+ * Escape, closing the dialog, and Enter all decline.
85
+ */
86
+ declare function messageBoxConfirm(showMessageBox: ShowMessageBox): (request: AgentConfirmation) => Promise<boolean>;
87
+ //#endregion
4
88
  //#region src/desktop.d.ts
5
89
  /** What the application does with the window it opened. */
6
90
  interface DesktopWindowTransport {
@@ -99,5 +183,5 @@ declare const DesktopWindows: ChannelToken<DesktopWindowsView, DesktopWindowsCom
99
183
  /** Serves `DesktopWindows` to one window. Put it in `channels`. */
100
184
  declare function windowsChannel(app: DesktopApp, window: DesktopWindowHandle): ServedChannel;
101
185
  //#endregion
102
- export { DesktopApp, DesktopAppOptions, DesktopWindowHandle, DesktopWindowTransport, DesktopWindows, DesktopWindowsCommands, DesktopWindowsView, createDesktopApp, windowsChannel };
186
+ export { type DesktopAgent, type DesktopAgentOptions, DesktopApp, DesktopAppOptions, type DesktopServe, DesktopWindowHandle, DesktopWindowTransport, DesktopWindows, DesktopWindowsCommands, DesktopWindowsView, type ShowMessageBox, createDesktopApp, messageBoxConfirm, serveDesktopAgent, windowsChannel };
103
187
  //# sourceMappingURL=desktop.d.ts.map
package/dist/desktop.js CHANGED
@@ -1,6 +1,76 @@
1
1
  import { serveChannelsToWindow } from "./main.js";
2
2
  import { channel } from "gesso-framework";
3
3
  import { BehaviorSubject } from "rxjs";
4
+ import { agentSurface, mcpHandler } from "gesso-framework/agent";
5
+ //#region src/agent.ts
6
+ const DEFAULT_PORT = 7310;
7
+ const PORTS_TRIED = 10;
8
+ function serveDesktopAgent(channels, options = {}) {
9
+ const serve = options.serve ?? bunServe();
10
+ const hostname = options.hostname ?? "127.0.0.1";
11
+ const first = options.port ?? DEFAULT_PORT;
12
+ const surface = agentSurface(channels, options.confirm === void 0 ? {} : { confirm: options.confirm });
13
+ const fetch = mcpHandler(surface, {
14
+ ...options,
15
+ allowedOrigins: []
16
+ });
17
+ let lastError;
18
+ for (let port = first; port < first + PORTS_TRIED; port++) try {
19
+ const server = serve({
20
+ hostname,
21
+ port,
22
+ fetch
23
+ });
24
+ return {
25
+ url: `http://${hostname}:${port}/mcp`,
26
+ surface,
27
+ stop: () => {
28
+ server.stop(true);
29
+ surface.dispose();
30
+ }
31
+ };
32
+ } catch (error) {
33
+ if (!isAddressInUse(error)) {
34
+ surface.dispose();
35
+ throw error;
36
+ }
37
+ lastError = error;
38
+ }
39
+ surface.dispose();
40
+ throw new Error(`Ports ${first} to ${first + PORTS_TRIED - 1} are all in use, so agents cannot be served. Pass another port to serveDesktopAgent. (${String(lastError)})`);
41
+ }
42
+ /**
43
+ * A `confirm` that asks with the operating system's own dialog.
44
+ *
45
+ * Takes Electrobun's `Utils.showMessageBox` rather than importing it,
46
+ * because Electrobun is a toolchain a project is projected into, not a
47
+ * package this one can depend on. The safe answer is the default one:
48
+ * Escape, closing the dialog, and Enter all decline.
49
+ */
50
+ function messageBoxConfirm(showMessageBox) {
51
+ return async (request) => {
52
+ const details = [request.description, Object.keys(request.arguments).length > 0 ? JSON.stringify(request.arguments, null, 2) : void 0].filter((part) => part !== void 0 && part !== "").join("\n\n");
53
+ const { response } = await showMessageBox({
54
+ type: request.destructive ? "warning" : "question",
55
+ title: "An AI agent is asking",
56
+ message: `An AI agent wants to ${request.command} in ${request.channel}.${request.destructive ? " This cannot be undone." : ""}`,
57
+ detail: details,
58
+ buttons: ["Allow", "Decline"],
59
+ defaultId: 1,
60
+ cancelId: 1
61
+ });
62
+ return response === 0;
63
+ };
64
+ }
65
+ function bunServe() {
66
+ const bun = globalThis.Bun;
67
+ if (typeof bun?.serve !== "function") throw new Error("serveDesktopAgent needs Bun.serve, which Cottontail and Bun provide. Pass serve to use another server.");
68
+ return (options) => bun.serve(options);
69
+ }
70
+ function isAddressInUse(error) {
71
+ return error?.code === "EADDRINUSE" || /in use|EADDRINUSE/i.test(String(error?.message ?? error));
72
+ }
73
+ //#endregion
4
74
  //#region src/desktop.ts
5
75
  /**
6
76
  * An application in the main process: windows, and the channels each
@@ -148,6 +218,6 @@ function windowsChannel(app, window) {
148
218
  };
149
219
  }
150
220
  //#endregion
151
- export { DesktopWindows, createDesktopApp, windowsChannel };
221
+ export { DesktopWindows, createDesktopApp, messageBoxConfirm, serveDesktopAgent, windowsChannel };
152
222
 
153
223
  //# sourceMappingURL=desktop.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"desktop.js","names":[],"sources":["../src/desktop.ts"],"sourcesContent":["/**\n * An application in the main process: windows, and the channels each\n * one is served.\n *\n * Nothing here imports Electrobun, and that is not fastidiousness. The\n * SDK is projected into a project by Hutch rather than installed from\n * a registry, so a package in\n * this workspace could not import it even if it wanted to. What the\n * application supplies instead is one function that opens a window,\n * which is the only Electrobun-shaped thing this needs, and which is\n * five lines at the call site:\n *\n * const app = createDesktopApp({\n * channels: window => [\n * { token: Catalogue, source: catalogue },\n * windowsChannel(app, window)\n * ],\n * open: receive => {\n * const rpc = BrowserView.defineRPC<GessoWindowRPC>({\n * handlers: { requests: {}, messages: { gessoFrame: receive } }\n * });\n * const window = new BrowserWindow({ title: 'Notes', url: 'views://mainview/index.html', rpc });\n * return {\n * send: frame => window.webview.rpc.send.gessoFrame(frame),\n * close: () => window.close()\n * };\n * }\n * });\n * app.openWindow();\n *\n * The arrangement it buys: every\n * window is a replica of the same channels, so two windows agree by\n * construction rather than by synchronisation.\n */\nimport { channel, type ChannelToken, type ServedChannel } from 'gesso-framework';\nimport { BehaviorSubject, type Observable, type Subscription } from 'rxjs';\n\nimport type { GessoFrame } from './frames';\nimport { serveChannelsToWindow, type ChannelHost } from './main';\n\n/** What the application does with the window it opened. */\nexport interface DesktopWindowTransport {\n /** Sends one frame to this window, over its own RPC. */\n send: (frame: GessoFrame) => void;\n /** Closes the native window. The adapter calls this; the platform may also close it on its own. */\n close: () => void;\n}\n\n/** A window this application opened. */\nexport interface DesktopWindowHandle {\n /** Stable for the life of the window, and never reused. */\n readonly id: number;\n /** Closes the window and disposes the channels it was served. */\n close(): void;\n}\n\nexport interface DesktopAppOptions {\n /**\n * The channels each window is served.\n *\n * A function when a window needs a channel of its own, which is what\n * `windowsChannel` uses to give a window a way to close itself. It\n * is called once per window, and the observables it returns are\n * ordinarily the same ones every time: `provide` keeps a separate\n * record of what each client has seen, so sharing a source between\n * windows is what makes them agree.\n */\n channels: readonly ServedChannel[] | ((window: DesktopWindowHandle) => readonly ServedChannel[]);\n /**\n * Opens a native window.\n *\n * `receive` is what the window's frames must be fed into: wire it to\n * the RPC message the window sends frames on, before the window\n * opens, or the first handshake is lost.\n */\n open: (receive: (frame: GessoFrame) => void, window: DesktopWindowHandle) => DesktopWindowTransport;\n /**\n * A url a window asked to have opened outside itself, with the\n * window that asked. `Utils.openExternal(url)` is what an Electrobun\n * application passes here.\n */\n onOpenUrl?: (url: string, window: DesktopWindowHandle) => void;\n /**\n * The appearance the platform is in, pushed to every window as it\n * changes and to a new window as it opens.\n *\n * The application supplies it because Electrobun does not: its SDK\n * has no appearance API at all, and the webview's own\n * `prefers-color-scheme` is wrong on WebKitGTK\n *. On a platform that has a\n * signal, this is where it goes; on one that does not, an\n * application setting is a perfectly good source.\n */\n colorScheme?: Observable<'light' | 'dark'>;\n /**\n * Called when the last window closes. A desktop application usually\n * stops here; one with a tray or a menu bar does not, which is why\n * this is a callback rather than an exit.\n */\n onLastWindowClosed?: () => void;\n /** Overrides the frame size messages are split at. Only a test should need to. */\n chunkBytes?: number;\n}\n\nexport interface DesktopApp {\n /** Opens a window, serves it every channel, and returns its handle. */\n openWindow(): DesktopWindowHandle;\n /** The windows open now, in the order they were opened. */\n readonly windows: readonly DesktopWindowHandle[];\n /** How many windows are open, as something a channel can publish. */\n readonly windowCount: Observable<number>;\n /** Closes every window and stops serving. */\n dispose(): void;\n}\n\nexport function createDesktopApp(options: DesktopAppOptions): DesktopApp {\n interface Entry {\n handle: DesktopWindowHandle;\n transport: DesktopWindowTransport;\n host: ChannelHost;\n }\n const entries = new Map<number, Entry>();\n /** One appearance subscription per window, ended when it closes. */\n const subscriptions = new Map<number, Subscription>();\n const order: DesktopWindowHandle[] = [];\n const count = new BehaviorSubject(0);\n let nextId = 1;\n let disposed = false;\n\n const forget = (id: number, closeNative: boolean): void => {\n const entry = entries.get(id);\n if (entry === undefined) {\n return;\n }\n entries.delete(id);\n subscriptions.get(id)?.unsubscribe();\n subscriptions.delete(id);\n const at = order.indexOf(entry.handle);\n if (at >= 0) {\n order.splice(at, 1);\n }\n entry.host.dispose();\n if (closeNative) {\n entry.transport.close();\n }\n count.next(order.length);\n if (order.length === 0 && !disposed) {\n options.onLastWindowClosed?.();\n }\n };\n\n const app: DesktopApp = {\n openWindow(): DesktopWindowHandle {\n if (disposed) {\n throw new Error('This desktop application has been disposed; it cannot open a window.');\n }\n const id = nextId++;\n const handle: DesktopWindowHandle = {\n id,\n close: () => forget(id, true)\n };\n\n // The host exists before the window does, because `open` may\n // deliver a frame synchronously and a window whose first\n // handshake was dropped never replicates anything.\n let transport: DesktopWindowTransport | undefined;\n const host = serveChannelsToWindow(\n typeof options.channels === 'function' ? options.channels(handle) : options.channels,\n {\n send: frame => transport?.send(frame),\n chunkBytes: options.chunkBytes,\n onOpenUrl: url => options.onOpenUrl?.(url, handle)\n }\n );\n entries.set(id, { handle, transport: { send: () => {}, close: () => {} }, host });\n order.push(handle);\n\n // Anything pushed before the window has spoken is lost: a\n // webview's RPC is not listening until its page has loaded, and\n // the window opens well before that. Channels do not notice,\n // because a channel starts with the window asking. The\n // appearance is the one thing this side sends first, so it waits\n // for the window's first frame, whatever that frame is. A native\n // window found this; a spec with a transport that was live\n // immediately could not.\n let greeted = false;\n let latest: 'light' | 'dark' | undefined;\n transport = options.open(frame => {\n if (!greeted) {\n greeted = true;\n if (latest !== undefined) {\n host.setColorScheme(latest);\n }\n }\n host.receive(frame);\n }, handle);\n entries.set(id, { handle, transport, host });\n const appearance = options.colorScheme?.subscribe(scheme => {\n latest = scheme;\n if (greeted) {\n host.setColorScheme(scheme);\n }\n });\n if (appearance !== undefined) {\n subscriptions.set(id, appearance);\n }\n count.next(order.length);\n return handle;\n },\n get windows(): readonly DesktopWindowHandle[] {\n return [...order];\n },\n windowCount: count.asObservable(),\n dispose(): void {\n disposed = true;\n for (const id of new Set(entries.keys())) {\n forget(id, true);\n }\n count.complete();\n }\n };\n\n return app;\n}\n\n/** What a window can see and do about the windows of its application. */\nexport interface DesktopWindowsView {\n /** How many windows this application has open. */\n count: number;\n /** Which one this is, so a screen can say \"window 2 of 3\" without asking. */\n id: number;\n}\n\nexport interface DesktopWindowsCommands {\n open: () => void;\n closeThis: () => void;\n}\n\n/**\n * The channel a window opens another window through.\n *\n * It exists because opening a window is the one native act a screen\n * genuinely needs, and going through a channel keeps the view layer\n * from importing an adapter. the \"native menus bound to\n * store actions\" is the same idea from the other end, and on Linux it\n * is the only end: the runtime has no application menus there, so a\n * menu is a component and this is what it calls.\n */\nexport const DesktopWindows: ChannelToken<DesktopWindowsView, DesktopWindowsCommands> = channel<\n DesktopWindowsView,\n DesktopWindowsCommands\n>('gesso:windows', { count: 0, id: 0 });\n\n/** Serves `DesktopWindows` to one window. Put it in `channels`. */\nexport function windowsChannel(app: DesktopApp, window: DesktopWindowHandle): ServedChannel {\n return {\n token: DesktopWindows,\n source: {\n view: {\n count: app.windowCount,\n id: new BehaviorSubject(window.id)\n },\n commands: {\n open: () => {\n app.openWindow();\n },\n closeThis: () => {\n window.close();\n }\n }\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmHA,SAAgB,iBAAiB,SAAwC;CAMvE,MAAM,0BAAU,IAAI,IAAmB;;CAEvC,MAAM,gCAAgB,IAAI,IAA0B;CACpD,MAAM,QAA+B,CAAC;CACtC,MAAM,QAAQ,IAAI,gBAAgB,CAAC;CACnC,IAAI,SAAS;CACb,IAAI,WAAW;CAEf,MAAM,UAAU,IAAY,gBAA+B;EACzD,MAAM,QAAQ,QAAQ,IAAI,EAAE;EAC5B,IAAI,UAAU,KAAA,GACZ;EAEF,QAAQ,OAAO,EAAE;EACjB,cAAc,IAAI,EAAE,CAAC,EAAE,YAAY;EACnC,cAAc,OAAO,EAAE;EACvB,MAAM,KAAK,MAAM,QAAQ,MAAM,MAAM;EACrC,IAAI,MAAM,GACR,MAAM,OAAO,IAAI,CAAC;EAEpB,MAAM,KAAK,QAAQ;EACnB,IAAI,aACF,MAAM,UAAU,MAAM;EAExB,MAAM,KAAK,MAAM,MAAM;EACvB,IAAI,MAAM,WAAW,KAAK,CAAC,UACzB,QAAQ,qBAAqB;CAEjC;CAyEA,OAAO;EAtEL,aAAkC;GAChC,IAAI,UACF,MAAM,IAAI,MAAM,sEAAsE;GAExF,MAAM,KAAK;GACX,MAAM,SAA8B;IAClC;IACA,aAAa,OAAO,IAAI,IAAI;GAC9B;GAKA,IAAI;GACJ,MAAM,OAAO,sBACX,OAAO,QAAQ,aAAa,aAAa,QAAQ,SAAS,MAAM,IAAI,QAAQ,UAC5E;IACE,OAAM,UAAS,WAAW,KAAK,KAAK;IACpC,YAAY,QAAQ;IACpB,YAAW,QAAO,QAAQ,YAAY,KAAK,MAAM;GACnD,CACF;GACA,QAAQ,IAAI,IAAI;IAAE;IAAQ,WAAW;KAAE,YAAY,CAAC;KAAG,aAAa,CAAC;IAAE;IAAG;GAAK,CAAC;GAChF,MAAM,KAAK,MAAM;GAUjB,IAAI,UAAU;GACd,IAAI;GACJ,YAAY,QAAQ,MAAK,UAAS;IAChC,IAAI,CAAC,SAAS;KACZ,UAAU;KACV,IAAI,WAAW,KAAA,GACb,KAAK,eAAe,MAAM;IAE9B;IACA,KAAK,QAAQ,KAAK;GACpB,GAAG,MAAM;GACT,QAAQ,IAAI,IAAI;IAAE;IAAQ;IAAW;GAAK,CAAC;GAC3C,MAAM,aAAa,QAAQ,aAAa,WAAU,WAAU;IAC1D,SAAS;IACT,IAAI,SACF,KAAK,eAAe,MAAM;GAE9B,CAAC;GACD,IAAI,eAAe,KAAA,GACjB,cAAc,IAAI,IAAI,UAAU;GAElC,MAAM,KAAK,MAAM,MAAM;GACvB,OAAO;EACT;EACA,IAAI,UAA0C;GAC5C,OAAO,CAAC,GAAG,KAAK;EAClB;EACA,aAAa,MAAM,aAAa;EAChC,UAAgB;GACd,WAAW;GACX,KAAK,MAAM,MAAM,IAAI,IAAI,QAAQ,KAAK,CAAC,GACrC,OAAO,IAAI,IAAI;GAEjB,MAAM,SAAS;EACjB;CAGO;AACX;;;;;;;;;;;AAyBA,MAAa,iBAA2E,QAGtF,iBAAiB;CAAE,OAAO;CAAG,IAAI;AAAE,CAAC;;AAGtC,SAAgB,eAAe,KAAiB,QAA4C;CAC1F,OAAO;EACL,OAAO;EACP,QAAQ;GACN,MAAM;IACJ,OAAO,IAAI;IACX,IAAI,IAAI,gBAAgB,OAAO,EAAE;GACnC;GACA,UAAU;IACR,YAAY;KACV,IAAI,WAAW;IACjB;IACA,iBAAiB;KACf,OAAO,MAAM;IACf;GACF;EACF;CACF;AACF"}
1
+ {"version":3,"file":"desktop.js","names":[],"sources":["../src/agent.ts","../src/desktop.ts"],"sourcesContent":["import type { ServedChannel } from 'gesso-framework';\nimport {\n agentSurface,\n mcpHandler,\n type AgentConfirmation,\n type AgentSurface,\n type McpHandlerOptions\n} from 'gesso-framework/agent';\n\n/**\n * A desktop application's channels, served to AI agents over MCP.\n *\n * The main process already holds every channel's source: it is what\n * `createDesktopApp` serves to each window. So it is also where an\n * agent connects. This serves those same sources with MCP's HTTP\n * transport on the person's own machine, through `Bun.serve`, which\n * Cottontail provides as Bun does, and an agent such as Claude Code\n * connects by URL:\n *\n * const agent = serveDesktopAgent([counter], { name: 'my-app', confirm: messageBoxConfirm(Utils.showMessageBox) });\n * // claude mcp add --transport http my-app http://127.0.0.1:7310/mcp\n *\n * The channels are passed rather than read from the app, because\n * `createDesktopApp` takes them per window, and the per-window ones\n * (`windowsChannel`) are about a window an agent does not have.\n *\n * What it offers is the channel tools only. The screen is in each\n * window's render worker, out of the main process's reach, so the\n * screen tools a web app offers are not here.\n *\n * The descriptions come from `channelSchema`. A main process is bundled\n * by Electrobun's own build, which `gesso-vite-plugin` never sees, so\n * the template runs `gesso-channels` to write a module that describes\n * the contracts, and imports it.\n */\n\n/** A `Bun.serve`, as far as this uses one. */\nexport type DesktopServe = (options: {\n hostname: string;\n port: number;\n fetch: (request: Request) => Promise<Response>;\n}) => { stop(closeActiveConnections?: boolean): void };\n\nexport interface DesktopAgentOptions extends Omit<McpHandlerOptions, 'allowedOrigins'> {\n /**\n * The port to listen on (default 7310). When it is taken, the next\n * nine are tried in turn, so a second copy of the app still serves;\n * `url` says which one it got.\n */\n port?: number;\n /** Default `127.0.0.1`: reachable from this machine only. */\n hostname?: string;\n /**\n * Asks the person whether a `@confirm` command may be sent. Without\n * it such a command is refused. `messageBoxConfirm` makes one from\n * Electrobun's native dialog.\n */\n confirm?: (request: AgentConfirmation) => boolean | Promise<boolean>;\n /** The server to start. Defaults to the runtime's `Bun.serve`. */\n serve?: DesktopServe;\n}\n\nexport interface DesktopAgent {\n /** Where an agent connects: `http://127.0.0.1:<port>/mcp`. */\n readonly url: string;\n readonly surface: AgentSurface;\n /** Stops serving and stops following the channels. */\n stop(): void;\n}\n\nconst DEFAULT_PORT = 7310;\nconst PORTS_TRIED = 10;\n\nexport function serveDesktopAgent(channels: readonly ServedChannel[], options: DesktopAgentOptions = {}): DesktopAgent {\n const serve = options.serve ?? bunServe();\n const hostname = options.hostname ?? '127.0.0.1';\n const first = options.port ?? DEFAULT_PORT;\n const surface = agentSurface(channels, options.confirm === undefined ? {} : { confirm: options.confirm });\n // A desktop app has no browser pages of its own to let in, so every\n // request carrying an Origin is refused: that is a web page the person\n // has open, reaching for their machine.\n const fetch = mcpHandler(surface, { ...options, allowedOrigins: [] });\n\n let lastError: unknown;\n for (let port = first; port < first + PORTS_TRIED; port++) {\n try {\n const server = serve({ hostname, port, fetch });\n return {\n url: `http://${hostname}:${port}/mcp`,\n surface,\n stop: () => {\n server.stop(true);\n surface.dispose();\n }\n };\n } catch (error) {\n if (!isAddressInUse(error)) {\n surface.dispose();\n throw error;\n }\n lastError = error;\n }\n }\n surface.dispose();\n throw new Error(\n `Ports ${first} to ${first + PORTS_TRIED - 1} are all in use, so agents cannot be served. ` +\n `Pass another port to serveDesktopAgent. (${String(lastError)})`\n );\n}\n\n/** The options Electrobun's `Utils.showMessageBox` takes, as far as this uses them. */\nexport type ShowMessageBox = (options: {\n type?: 'info' | 'warning' | 'error' | 'question';\n title?: string;\n message?: string;\n detail?: string;\n buttons?: string[];\n defaultId?: number;\n cancelId?: number;\n}) => Promise<{ response: number }>;\n\n/**\n * A `confirm` that asks with the operating system's own dialog.\n *\n * Takes Electrobun's `Utils.showMessageBox` rather than importing it,\n * because Electrobun is a toolchain a project is projected into, not a\n * package this one can depend on. The safe answer is the default one:\n * Escape, closing the dialog, and Enter all decline.\n */\nexport function messageBoxConfirm(showMessageBox: ShowMessageBox): (request: AgentConfirmation) => Promise<boolean> {\n return async request => {\n const details = [\n request.description,\n Object.keys(request.arguments).length > 0 ? JSON.stringify(request.arguments, null, 2) : undefined\n ]\n .filter((part): part is string => part !== undefined && part !== '')\n .join('\\n\\n');\n const { response } = await showMessageBox({\n type: request.destructive ? 'warning' : 'question',\n title: 'An AI agent is asking',\n message: `An AI agent wants to ${request.command} in ${request.channel}.${request.destructive ? ' This cannot be undone.' : ''}`,\n detail: details,\n buttons: ['Allow', 'Decline'],\n defaultId: 1,\n cancelId: 1\n });\n return response === 0;\n };\n}\n\nfunction bunServe(): DesktopServe {\n const bun = (globalThis as { Bun?: { serve?: DesktopServe } }).Bun;\n if (typeof bun?.serve !== 'function') {\n throw new Error(\n 'serveDesktopAgent needs Bun.serve, which Cottontail and Bun provide. Pass serve to use another server.'\n );\n }\n return options => bun.serve!(options);\n}\n\nfunction isAddressInUse(error: unknown): boolean {\n const code = (error as { code?: unknown } | null)?.code;\n return code === 'EADDRINUSE' || /in use|EADDRINUSE/i.test(String((error as Error | null)?.message ?? error));\n}\n","/**\n * An application in the main process: windows, and the channels each\n * one is served.\n *\n * Nothing here imports Electrobun, and that is not fastidiousness. The\n * SDK is projected into a project by Hutch rather than installed from\n * a registry, so a package in\n * this workspace could not import it even if it wanted to. What the\n * application supplies instead is one function that opens a window,\n * which is the only Electrobun-shaped thing this needs, and which is\n * five lines at the call site:\n *\n * const app = createDesktopApp({\n * channels: window => [\n * { token: Catalogue, source: catalogue },\n * windowsChannel(app, window)\n * ],\n * open: receive => {\n * const rpc = BrowserView.defineRPC<GessoWindowRPC>({\n * handlers: { requests: {}, messages: { gessoFrame: receive } }\n * });\n * const window = new BrowserWindow({ title: 'Notes', url: 'views://mainview/index.html', rpc });\n * return {\n * send: frame => window.webview.rpc.send.gessoFrame(frame),\n * close: () => window.close()\n * };\n * }\n * });\n * app.openWindow();\n *\n * The arrangement it buys: every\n * window is a replica of the same channels, so two windows agree by\n * construction rather than by synchronisation.\n */\nimport { channel, type ChannelToken, type ServedChannel } from 'gesso-framework';\nimport { BehaviorSubject, type Observable, type Subscription } from 'rxjs';\n\nimport type { GessoFrame } from './frames';\nimport { serveChannelsToWindow, type ChannelHost } from './main';\n\n/** What the application does with the window it opened. */\nexport interface DesktopWindowTransport {\n /** Sends one frame to this window, over its own RPC. */\n send: (frame: GessoFrame) => void;\n /** Closes the native window. The adapter calls this; the platform may also close it on its own. */\n close: () => void;\n}\n\n/** A window this application opened. */\nexport interface DesktopWindowHandle {\n /** Stable for the life of the window, and never reused. */\n readonly id: number;\n /** Closes the window and disposes the channels it was served. */\n close(): void;\n}\n\nexport interface DesktopAppOptions {\n /**\n * The channels each window is served.\n *\n * A function when a window needs a channel of its own, which is what\n * `windowsChannel` uses to give a window a way to close itself. It\n * is called once per window, and the observables it returns are\n * ordinarily the same ones every time: `provide` keeps a separate\n * record of what each client has seen, so sharing a source between\n * windows is what makes them agree.\n */\n channels: readonly ServedChannel[] | ((window: DesktopWindowHandle) => readonly ServedChannel[]);\n /**\n * Opens a native window.\n *\n * `receive` is what the window's frames must be fed into: wire it to\n * the RPC message the window sends frames on, before the window\n * opens, or the first handshake is lost.\n */\n open: (receive: (frame: GessoFrame) => void, window: DesktopWindowHandle) => DesktopWindowTransport;\n /**\n * A url a window asked to have opened outside itself, with the\n * window that asked. `Utils.openExternal(url)` is what an Electrobun\n * application passes here.\n */\n onOpenUrl?: (url: string, window: DesktopWindowHandle) => void;\n /**\n * The appearance the platform is in, pushed to every window as it\n * changes and to a new window as it opens.\n *\n * The application supplies it because Electrobun does not: its SDK\n * has no appearance API at all, and the webview's own\n * `prefers-color-scheme` is wrong on WebKitGTK\n *. On a platform that has a\n * signal, this is where it goes; on one that does not, an\n * application setting is a perfectly good source.\n */\n colorScheme?: Observable<'light' | 'dark'>;\n /**\n * Called when the last window closes. A desktop application usually\n * stops here; one with a tray or a menu bar does not, which is why\n * this is a callback rather than an exit.\n */\n onLastWindowClosed?: () => void;\n /** Overrides the frame size messages are split at. Only a test should need to. */\n chunkBytes?: number;\n}\n\nexport interface DesktopApp {\n /** Opens a window, serves it every channel, and returns its handle. */\n openWindow(): DesktopWindowHandle;\n /** The windows open now, in the order they were opened. */\n readonly windows: readonly DesktopWindowHandle[];\n /** How many windows are open, as something a channel can publish. */\n readonly windowCount: Observable<number>;\n /** Closes every window and stops serving. */\n dispose(): void;\n}\n\nexport function createDesktopApp(options: DesktopAppOptions): DesktopApp {\n interface Entry {\n handle: DesktopWindowHandle;\n transport: DesktopWindowTransport;\n host: ChannelHost;\n }\n const entries = new Map<number, Entry>();\n /** One appearance subscription per window, ended when it closes. */\n const subscriptions = new Map<number, Subscription>();\n const order: DesktopWindowHandle[] = [];\n const count = new BehaviorSubject(0);\n let nextId = 1;\n let disposed = false;\n\n const forget = (id: number, closeNative: boolean): void => {\n const entry = entries.get(id);\n if (entry === undefined) {\n return;\n }\n entries.delete(id);\n subscriptions.get(id)?.unsubscribe();\n subscriptions.delete(id);\n const at = order.indexOf(entry.handle);\n if (at >= 0) {\n order.splice(at, 1);\n }\n entry.host.dispose();\n if (closeNative) {\n entry.transport.close();\n }\n count.next(order.length);\n if (order.length === 0 && !disposed) {\n options.onLastWindowClosed?.();\n }\n };\n\n const app: DesktopApp = {\n openWindow(): DesktopWindowHandle {\n if (disposed) {\n throw new Error('This desktop application has been disposed; it cannot open a window.');\n }\n const id = nextId++;\n const handle: DesktopWindowHandle = {\n id,\n close: () => forget(id, true)\n };\n\n // The host exists before the window does, because `open` may\n // deliver a frame synchronously and a window whose first\n // handshake was dropped never replicates anything.\n let transport: DesktopWindowTransport | undefined;\n const host = serveChannelsToWindow(\n typeof options.channels === 'function' ? options.channels(handle) : options.channels,\n {\n send: frame => transport?.send(frame),\n chunkBytes: options.chunkBytes,\n onOpenUrl: url => options.onOpenUrl?.(url, handle)\n }\n );\n entries.set(id, { handle, transport: { send: () => {}, close: () => {} }, host });\n order.push(handle);\n\n // Anything pushed before the window has spoken is lost: a\n // webview's RPC is not listening until its page has loaded, and\n // the window opens well before that. Channels do not notice,\n // because a channel starts with the window asking. The\n // appearance is the one thing this side sends first, so it waits\n // for the window's first frame, whatever that frame is. A native\n // window found this; a spec with a transport that was live\n // immediately could not.\n let greeted = false;\n let latest: 'light' | 'dark' | undefined;\n transport = options.open(frame => {\n if (!greeted) {\n greeted = true;\n if (latest !== undefined) {\n host.setColorScheme(latest);\n }\n }\n host.receive(frame);\n }, handle);\n entries.set(id, { handle, transport, host });\n const appearance = options.colorScheme?.subscribe(scheme => {\n latest = scheme;\n if (greeted) {\n host.setColorScheme(scheme);\n }\n });\n if (appearance !== undefined) {\n subscriptions.set(id, appearance);\n }\n count.next(order.length);\n return handle;\n },\n get windows(): readonly DesktopWindowHandle[] {\n return [...order];\n },\n windowCount: count.asObservable(),\n dispose(): void {\n disposed = true;\n for (const id of new Set(entries.keys())) {\n forget(id, true);\n }\n count.complete();\n }\n };\n\n return app;\n}\n\n/** What a window can see and do about the windows of its application. */\nexport interface DesktopWindowsView {\n /** How many windows this application has open. */\n count: number;\n /** Which one this is, so a screen can say \"window 2 of 3\" without asking. */\n id: number;\n}\n\nexport interface DesktopWindowsCommands {\n open: () => void;\n closeThis: () => void;\n}\n\n/**\n * The channel a window opens another window through.\n *\n * It exists because opening a window is the one native act a screen\n * genuinely needs, and going through a channel keeps the view layer\n * from importing an adapter. the \"native menus bound to\n * store actions\" is the same idea from the other end, and on Linux it\n * is the only end: the runtime has no application menus there, so a\n * menu is a component and this is what it calls.\n */\nexport const DesktopWindows: ChannelToken<DesktopWindowsView, DesktopWindowsCommands> = channel<\n DesktopWindowsView,\n DesktopWindowsCommands\n>('gesso:windows', { count: 0, id: 0 });\n\n/** Serves `DesktopWindows` to one window. Put it in `channels`. */\nexport function windowsChannel(app: DesktopApp, window: DesktopWindowHandle): ServedChannel {\n return {\n token: DesktopWindows,\n source: {\n view: {\n count: app.windowCount,\n id: new BehaviorSubject(window.id)\n },\n commands: {\n open: () => {\n app.openWindow();\n },\n closeThis: () => {\n window.close();\n }\n }\n }\n };\n}\n\nexport {\n messageBoxConfirm,\n serveDesktopAgent,\n type DesktopAgent,\n type DesktopAgentOptions,\n type DesktopServe,\n type ShowMessageBox\n} from './agent';\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gesso-electrobun",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Run a Gesso application in an Electrobun window, with its stores in the main process.",
5
5
  "license": "MIT",
6
6
  "author": "Kevin Baker",
@@ -54,7 +54,7 @@
54
54
  "LICENSE"
55
55
  ],
56
56
  "dependencies": {
57
- "gesso-framework": "^0.4.1"
57
+ "gesso-framework": "^0.5.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "rxjs": "^7.8.2"