@fungi.computer/tourtois 0.0.0-stage → 0.1.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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -2
  3. package/dist/AgentMcpServers.d.ts +34 -0
  4. package/dist/AgentMcpServers.js +124 -0
  5. package/dist/AppAccessConsent.d.ts +23 -0
  6. package/dist/AppAccessConsent.js +122 -0
  7. package/dist/AppCallPermissions.d.ts +9 -0
  8. package/dist/AppCallPermissions.js +21 -0
  9. package/dist/AppDesktopSettings.d.ts +11 -0
  10. package/dist/AppDesktopSettings.js +102 -0
  11. package/dist/AppPane.d.ts +37 -0
  12. package/dist/AppPane.js +219 -0
  13. package/dist/AppWorkbench.d.ts +44 -0
  14. package/dist/AppWorkbench.js +575 -0
  15. package/dist/AvatarPicker.d.ts +7 -0
  16. package/dist/AvatarPicker.js +78 -0
  17. package/dist/AvatarPreview.d.ts +10 -0
  18. package/dist/AvatarPreview.js +6 -0
  19. package/dist/DesktopMenuBar.d.ts +18 -0
  20. package/dist/DesktopMenuBar.js +23 -0
  21. package/dist/FocusedAppMenus.d.ts +2 -0
  22. package/dist/FocusedAppMenus.js +10 -0
  23. package/dist/Keys.d.ts +131 -0
  24. package/dist/Keys.js +201 -0
  25. package/dist/MushroomMark.d.ts +2 -0
  26. package/dist/MushroomMark.js +5 -0
  27. package/dist/SettingsHome.d.ts +18 -0
  28. package/dist/SettingsHome.js +48 -0
  29. package/dist/SettingsNavigation.d.ts +17 -0
  30. package/dist/SettingsNavigation.js +153 -0
  31. package/dist/SystemSettings.d.ts +24 -0
  32. package/dist/SystemSettings.js +177 -0
  33. package/dist/TeamDesktop.d.ts +36 -0
  34. package/dist/TeamDesktop.js +39 -0
  35. package/dist/WorkbenchAppBar.d.ts +5 -0
  36. package/dist/WorkbenchAppBar.js +12 -0
  37. package/dist/app-authority.d.ts +150 -0
  38. package/dist/app-authority.js +41 -0
  39. package/dist/app-directory-reconciliation.d.ts +14 -0
  40. package/dist/app-directory-reconciliation.js +72 -0
  41. package/dist/app-directory.d.ts +206 -0
  42. package/dist/app-directory.js +21 -0
  43. package/dist/app-icon.d.ts +9 -0
  44. package/dist/app-icon.js +27 -0
  45. package/dist/app-launch.d.ts +53 -0
  46. package/dist/app-launch.js +204 -0
  47. package/dist/app-menus.d.ts +6 -0
  48. package/dist/app-menus.js +81 -0
  49. package/dist/app-open-url.d.ts +8 -0
  50. package/dist/app-open-url.js +63 -0
  51. package/dist/app-surface.d.ts +62 -0
  52. package/dist/app-surface.js +10 -0
  53. package/dist/app-windows.d.ts +134 -0
  54. package/dist/app-windows.js +21 -0
  55. package/dist/chrome.css +153 -0
  56. package/dist/command-dialogs.d.ts +42 -0
  57. package/dist/command-dialogs.js +194 -0
  58. package/dist/desktop/Desktop.d.ts +34 -0
  59. package/dist/desktop/Desktop.js +275 -0
  60. package/dist/desktop/consent.d.ts +4 -0
  61. package/dist/desktop/consent.js +64 -0
  62. package/dist/desktop/gill-frame.d.ts +14 -0
  63. package/dist/desktop/gill-frame.js +41 -0
  64. package/dist/desktop/gill-param-picker.d.ts +15 -0
  65. package/dist/desktop/gill-param-picker.js +145 -0
  66. package/dist/desktop/launcher-gill.d.ts +20 -0
  67. package/dist/desktop/launcher-gill.js +49 -0
  68. package/dist/desktop/own-launchers.d.ts +9 -0
  69. package/dist/desktop/own-launchers.js +9 -0
  70. package/dist/desktop/row-gill.d.ts +24 -0
  71. package/dist/desktop/row-gill.js +136 -0
  72. package/dist/desktop-api.d.ts +63 -0
  73. package/dist/desktop-api.js +31 -0
  74. package/dist/desktop-client.d.ts +19 -0
  75. package/dist/desktop-client.js +127 -0
  76. package/dist/desktop-layout.d.ts +117 -0
  77. package/dist/desktop-layout.js +305 -0
  78. package/dist/desktop-visibility.d.ts +8 -0
  79. package/dist/desktop-visibility.js +5 -0
  80. package/dist/layers.d.ts +5 -0
  81. package/dist/layers.js +31 -0
  82. package/dist/main.d.ts +2 -0
  83. package/dist/main.js +17 -0
  84. package/dist/member-avatar-cache.d.ts +15 -0
  85. package/dist/member-avatar-cache.js +69 -0
  86. package/dist/member-avatar.d.ts +39 -0
  87. package/dist/member-avatar.js +88 -0
  88. package/dist/menu-bar.d.ts +5 -0
  89. package/dist/menu-bar.js +5 -0
  90. package/dist/model.d.ts +6 -0
  91. package/dist/model.js +13 -0
  92. package/dist/oyster-platform-controls.d.ts +4 -0
  93. package/dist/oyster-platform-controls.js +107 -0
  94. package/dist/platform-controls.d.ts +84 -0
  95. package/dist/platform-controls.js +266 -0
  96. package/dist/queries.d.ts +7 -0
  97. package/dist/queries.js +7 -0
  98. package/dist/session-registry.d.ts +16 -0
  99. package/dist/session-registry.js +69 -0
  100. package/dist/session-selection.d.ts +6 -0
  101. package/dist/session-selection.js +22 -0
  102. package/dist/settings-leave-guard.d.ts +5 -0
  103. package/dist/settings-leave-guard.js +9 -0
  104. package/dist/settings-navigation.css +129 -0
  105. package/dist/settings-routes.d.ts +118 -0
  106. package/dist/settings-routes.js +216 -0
  107. package/dist/settings-sections.d.ts +74 -0
  108. package/dist/settings-sections.js +17 -0
  109. package/dist/shell-commands.d.ts +15 -0
  110. package/dist/shell-commands.js +95 -0
  111. package/dist/shell-host.d.ts +52 -0
  112. package/dist/shell-host.js +288 -0
  113. package/dist/shell-module.d.ts +12 -0
  114. package/dist/shell-module.js +17 -0
  115. package/dist/shell-release.d.ts +3 -0
  116. package/dist/shell-release.js +4 -0
  117. package/dist/shell.css +858 -0
  118. package/dist/shell.d.ts +6 -0
  119. package/dist/shell.js +83 -0
  120. package/dist/spa.css +11 -0
  121. package/dist/status-bar.d.ts +9 -0
  122. package/dist/status-bar.js +51 -0
  123. package/dist/team-directory.d.ts +3 -0
  124. package/dist/team-directory.js +6 -0
  125. package/dist/use-window-motion.d.ts +3 -0
  126. package/dist/use-window-motion.js +37 -0
  127. package/dist/useAppOpenUrlHandoff.d.ts +11 -0
  128. package/dist/useAppOpenUrlHandoff.js +62 -0
  129. package/dist/useAppWindowManager.d.ts +351 -0
  130. package/dist/useAppWindowManager.js +47 -0
  131. package/dist/useDirectoryCommands.d.ts +12 -0
  132. package/dist/useDirectoryCommands.js +80 -0
  133. package/dist/workbench.d.ts +3 -0
  134. package/dist/workbench.js +2 -0
  135. package/package.json +222 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,128 @@
1
- # Temporary Holding Version
1
+ # Tourtois (`@fungi.computer/tourtois`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Tourtois is the desktop for your Apps: a launcher, tiled or floating windows,
4
+ menus and settings.
5
+
6
+ ![The Tourtois desktop with three Apps tiled: Bonsai's network graph and repositories, Grove, and Shed's terminal](docs/media/desktop.png)
7
+
8
+ Tourtois is the shell of [Oyster](https://github.com/fungi-computer/oyster), the
9
+ open App host that runs on your own Cloudflare account. When you sign in to an
10
+ Oyster, Tourtois is what you see, and every other App opens inside it.
11
+
12
+ ## What you can do
13
+
14
+ - Launch Apps from the **Launcher** on the desktop or from the top bar.
15
+ - Arrange windows **Tiled** or **Floating** from the **Layout** menu: drag,
16
+ resize, zoom one window, minimize it and bring it back. A minimized App keeps
17
+ running, so restoring it never reloads it.
18
+ - Use the focused App's own menus in the top bar.
19
+ - Keep small live views of Apps on the desktop, such as Grove's inbox, and
20
+ **Arrange desktop** to add, move, resize and remove them.
21
+ - Open **System Settings…** from the mushroom menu for App permissions, keys and
22
+ agent connectors.
23
+ - On a phone, Apps open as a full-screen sheet over the desktop, and **Show
24
+ desktop** brings it back without losing unsaved work.
25
+
26
+ <img alt="Tourtois on a phone in the light theme: Grove's inbox above the Launcher" src="docs/media/desktop-phone.png" width="300">
27
+
28
+ Tourtois is a trusted shell. Every other App runs in its own sandboxed,
29
+ cross-origin frame, but Tourtois runs in the host's own page with your
30
+ permissions there. Pick a shell you trust: it can do anything you can on that
31
+ host.
32
+
33
+ ## Get it
34
+
35
+ Tourtois installs with the other starter Apps, first of all. On a deployed
36
+ Oyster (see the
37
+ [Oyster quick start](https://github.com/fungi-computer/oyster#quick-start-deploy-your-own-oyster)),
38
+ run:
39
+
40
+ ```sh
41
+ export OYSTER_URL=https://oyster.<your-subdomain>.workers.dev
42
+ export OYSTER_OWNER_KEY=<your owner key>
43
+ npx oyster install-apps
44
+ ```
45
+
46
+ The installer clones this repository and the six others, seeds each on your
47
+ Oyster with `git push`, builds them on your Computer and installs them. Sign in
48
+ at `$OYSTER_URL/login` with the owner key and your Oyster opens on Tourtois. Run
49
+ `install-apps` again to update; each App keeps its data.
50
+
51
+ ## Change it
52
+
53
+ Every installed App is also a template you can fork on your own Oyster,
54
+ including the desktop. Fork Tourtois into a Bonsai repository and clone it with
55
+ ordinary `git`:
56
+
57
+ ```sh
58
+ curl -X POST -H "Authorization: Bearer $OYSTER_OWNER_KEY" -H 'content-type: application/json' \
59
+ -d '{"appId":"app_tourtois","name":"my-tourtois"}' \
60
+ $OYSTER_URL/apps/bonsai/methods/apps.fork
61
+ git -c http.extraHeader="Authorization: Bearer $OYSTER_OWNER_KEY" \
62
+ clone $OYSTER_URL/git/bonsai/my-tourtois.git
63
+ ```
64
+
65
+ The reply names your fork's App id and repository. Push your edits, open the
66
+ repository in Bonsai and choose **Settings**, then **Build & install** with that
67
+ App id and your commit as `git:<sha>`. Install the Release at `tourtois` to
68
+ replace the desktop. Building alone never changes what you see; only the install
69
+ does.
70
+
71
+ To work from GitHub instead, fork this repository and point the installer at
72
+ your forks with `--repositories <base URL>` and `--ref <branch or tag>`. The
73
+ installer reads all seven starter Apps from that base, so fork them under one
74
+ account.
75
+
76
+ ## How a host mounts it
77
+
78
+ A Tourtois Release ships `shell.js` beside its `index.html`. The host imports
79
+ that module from its own origin and calls
80
+ `mountShell(root, { workbench, storage, methods })`, which renders the desktop
81
+ into the host's page with the host's workbench, Tourtois's own storage and its
82
+ methods. Tourtois's code loads as one lazy chunk behind that module, so no host
83
+ bundles it, and a fork mounts the same way.
84
+
85
+ Tourtois asks the host to mount each App in that App's own sandboxed frame,
86
+ under its own installation, Release and permissions. Settings pages are supplied
87
+ by the host too: Tourtois offers a page only when the host backs it.
88
+
89
+ ## Agent tools
90
+
91
+ Agents can arrange your desktop. Through Oyster's MCP endpoint, or at
92
+ `POST $OYSTER_URL/apps/tourtois/methods/<name>` with a key (see
93
+ [Git, keys and MCP](https://github.com/fungi-computer/oyster#git-keys-and-mcp)),
94
+ they call:
95
+
96
+ | Method | What it does |
97
+ | ------------------------------------------------------------------ | --------------------------------------------------------------- |
98
+ | `layout.get` / `widgets.available` | Reads the desktop and the live views Apps offer. |
99
+ | `layout.place` / `layout.move` / `layout.resize` / `layout.remove` | Changes one item on the desktop. |
100
+ | `layout.launcher` / `layout.set` / `layout.reset` | Pins or hides launcher Apps, replaces the layout, or resets it. |
101
+
102
+ Each change names the revision it is based on, and a stale revision is refused
103
+ rather than overwriting a newer arrangement. In a connected browser, an agent's
104
+ change shows **Your Agent arranged the desktop** with **Undo** for ten seconds.
105
+
106
+ ## Build and check
107
+
108
+ Tourtois is React and TypeScript. Windows and tiling come from
109
+ [Maosaico](https://github.com/fungi-computer/maosaico) and the controls from
110
+ [Caps](https://github.com/fungi-computer/caps).
111
+
112
+ ```sh
113
+ pnpm install
114
+ pnpm build
115
+ pnpm typecheck
116
+ pnpm test
117
+ ```
118
+
119
+ [src/AppWorkbench.tsx](src/AppWorkbench.tsx) composes the App list, windows and
120
+ layout. [src/shell-module.ts](src/shell-module.ts) is the `mountShell` entry,
121
+ [src/platform-controls.tsx](src/platform-controls.tsx) the Settings window, and
122
+ [src/settings-sections.ts](src/settings-sections.ts) the Settings pages a host
123
+ can supply. The agent tools are in [backend/methods.ts](backend/methods.ts), and
124
+ [package.json](package.json) declares what the shell asks of its host.
125
+
126
+ ## License
127
+
128
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,34 @@
1
+ import * as z from "zod";
2
+ /** Every Agent with its connected MCP servers. */
3
+ export declare const agentMcpServersViewSchema: z.ZodObject<{
4
+ agents: z.ZodArray<z.ZodObject<{
5
+ agentId: z.ZodString;
6
+ name: z.ZodString;
7
+ servers: z.ZodArray<z.ZodObject<{
8
+ serverId: z.ZodString;
9
+ name: z.ZodString;
10
+ url: z.ZodString;
11
+ hasBearer: z.ZodBoolean;
12
+ }, z.core.$strict>>;
13
+ }, z.core.$strict>>;
14
+ }, z.core.$strict>;
15
+ export type AgentMcpServersView = z.output<typeof agentMcpServersViewSchema>;
16
+ /** What the owner submits to connect one server. */
17
+ export type AgentMcpServerDraft = Readonly<{
18
+ name: string;
19
+ url: string;
20
+ bearer: string | null;
21
+ }>;
22
+ /** The host's owner-direct custody for Agent MCP servers. */
23
+ export type AgentMcpServersPort = Readonly<{
24
+ list(): Promise<AgentMcpServersView>;
25
+ add(agentId: string, draft: AgentMcpServerDraft): Promise<void>;
26
+ remove(agentId: string, serverId: string): Promise<void>;
27
+ }>;
28
+ /**
29
+ * The Connectors settings on a standalone host: connect a remote MCP server to
30
+ * an Agent. Its tools become `env.mcp.<name>.<tool>()` in that Agent's code.
31
+ */
32
+ export declare function AgentMcpServers({ port }: {
33
+ port: AgentMcpServersPort;
34
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,124 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { SectionHeading } from "@fungi.computer/caps/blocks/section-heading";
3
+ import { ActionRow } from "@fungi.computer/caps/components/action-row";
4
+ import { Badge } from "@fungi.computer/caps/components/badge";
5
+ import { Button } from "@fungi.computer/caps/components/button";
6
+ import { DataList, DataListHeading, DataRow, } from "@fungi.computer/caps/components/data-list";
7
+ import { FormFooter } from "@fungi.computer/caps/components/field";
8
+ import { SchemaForm, field, useSchemaForm } from "@fungi.computer/caps/form";
9
+ import { Sigil } from "@fungi.computer/sigils";
10
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
11
+ import { Fragment, useMemo, useState } from "react";
12
+ import * as z from "zod";
13
+ /** One connected MCP server as the host lists it; the token never comes back. */
14
+ const agentMcpServerSchema = z.strictObject({
15
+ serverId: z.string().min(1),
16
+ name: z.string().min(1),
17
+ url: z.string().min(1),
18
+ hasBearer: z.boolean(),
19
+ });
20
+ /** Every Agent with its connected MCP servers. */
21
+ export const agentMcpServersViewSchema = z.strictObject({
22
+ agents: z.array(z.strictObject({
23
+ agentId: z.string().min(1),
24
+ name: z.string(),
25
+ servers: z.array(agentMcpServerSchema),
26
+ })),
27
+ });
28
+ const queryKey = ["tourtois", "agent-mcp-servers"];
29
+ /**
30
+ * The Connectors settings on a standalone host: connect a remote MCP server to
31
+ * an Agent. Its tools become `env.mcp.<name>.<tool>()` in that Agent's code.
32
+ */
33
+ export function AgentMcpServers({ port }) {
34
+ const client = useQueryClient();
35
+ const view = useQuery({ queryKey, queryFn: () => port.list() });
36
+ const refresh = () => client.invalidateQueries({ queryKey });
37
+ const remove = useMutation({
38
+ mutationFn: (input) => port.remove(input.agentId, input.serverId),
39
+ onSuccess: refresh,
40
+ });
41
+ const agents = view.data?.agents ?? [];
42
+ // A fresh form after each connected server, so the token field empties.
43
+ const [formKey, setFormKey] = useState(0);
44
+ return (_jsxs("section", { className: "tourtois-settings-content tourtois-connectors", "aria-label": "Connectors", children: [_jsx(SectionHeading, { children: "Connectors" }), _jsxs("p", { className: "tourtois-settings-lede", children: ["Connect a remote MCP server to an Agent. Its tools become", " ", _jsx("code", { children: "env.mcp.<name>" }), " in that Agent's code. A token stays with the Agent and is never shown again."] }), view.isPending ? _jsx("p", { children: "Loading MCP servers\u2026" }) : null, view.isError ? (_jsxs("div", { role: "status", children: [_jsx("p", { children: "MCP servers could not be loaded." }), _jsx(Button, { size: "sm", onClick: () => void view.refetch(), children: "Retry" })] })) : null, view.isSuccess && agents.length === 0 ? (_jsx("p", { children: "Create an Agent first, then connect MCP servers to it." })) : null, agents.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(DataList, { "aria-label": "Agent MCP servers", children: agents.map((agent) => (_jsxs(Fragment, { children: [_jsx(DataListHeading, { children: agent.name }), agent.servers.length === 0 ? (_jsx(DataRow, { children: "No MCP servers." })) : null, agent.servers.map((server) => (_jsx(DataRow, { className: "p-0", children: _jsx(ActionRow, { icon: _jsx(Sigil, { name: "connected" }), label: _jsxs(_Fragment, { children: [server.name, " ", _jsx(Badge, { size: "sm", variant: "quiet", children: "MCP" })] }), description: server.url, trailing: _jsxs(_Fragment, { children: [server.hasBearer ? (_jsx(Badge, { size: "sm", variant: "quiet", children: "Token saved" })) : null, _jsx(Button, { size: "sm", variant: "ghost", "aria-label": `Remove ${server.name} from ${agent.name}`, disabled: remove.isPending, onClick: () => {
45
+ remove.mutate({
46
+ agentId: agent.agentId,
47
+ serverId: server.serverId,
48
+ });
49
+ }, children: "Remove" })] }) }) }, server.serverId)))] }, agent.agentId))) }), remove.isError ? (_jsx("p", { role: "alert", children: "The MCP server could not be removed." })) : null, _jsx(AddMcpServer, { agents: agents, onAdd: async (agentId, draft) => {
50
+ await port.add(agentId, draft);
51
+ await refresh();
52
+ setFormKey((current) => current + 1);
53
+ } }, formKey)] })) : null] }));
54
+ }
55
+ function AddMcpServer({ agents, onAdd, }) {
56
+ const schema = useMemo(() => z.object({
57
+ agentId: z.string().meta(field({
58
+ label: "Agent",
59
+ kind: "select",
60
+ options: Object.fromEntries(agents.map((agent) => [agent.agentId, agent.name])),
61
+ })),
62
+ name: z
63
+ .string()
64
+ .regex(/^[a-z][a-z0-9_]{0,31}$/u, "Lowercase letters, digits and _, starting with a letter")
65
+ .meta(field({
66
+ label: "Name",
67
+ help: "The Agent calls its tools as env.mcp.<name>.",
68
+ maxLength: 32,
69
+ placeholder: "linear",
70
+ })),
71
+ url: z
72
+ .string()
73
+ .max(2_048)
74
+ .refine((value) => URL.canParse(value) && new URL(value).protocol === "https:", "Use the server's https:// address")
75
+ .meta(field({
76
+ label: "Server URL",
77
+ kind: "url",
78
+ placeholder: "https://mcp.example.com/mcp",
79
+ })),
80
+ bearer: z
81
+ .string()
82
+ .max(4_096)
83
+ .meta(field({
84
+ label: "Bearer token",
85
+ help: "Only if the server asks for one.",
86
+ optional: true,
87
+ kind: "secret",
88
+ })),
89
+ }), [agents]);
90
+ const add = useMutation({
91
+ mutationFn: (input) => onAdd(input.agentId, input.draft),
92
+ });
93
+ const model = useSchemaForm({
94
+ schema,
95
+ defaultValues: {
96
+ agentId: agents[0]?.agentId ?? "",
97
+ name: "",
98
+ url: "",
99
+ bearer: "",
100
+ },
101
+ onSubmit: async (value) => {
102
+ try {
103
+ await add.mutateAsync({
104
+ agentId: value.agentId,
105
+ draft: {
106
+ name: value.name,
107
+ url: value.url,
108
+ bearer: value.bearer.length === 0 ? null : value.bearer,
109
+ },
110
+ });
111
+ return { kind: "saved" };
112
+ }
113
+ catch (cause) {
114
+ return {
115
+ kind: "refused",
116
+ error: cause instanceof Error
117
+ ? cause.message
118
+ : "The MCP server could not be connected.",
119
+ };
120
+ }
121
+ },
122
+ });
123
+ return (_jsx(SchemaForm, { model: model, "aria-label": "Add MCP server", disabled: add.isPending, footer: _jsx(FormFooter, { error: model.error, children: _jsx(Button, { type: "submit", children: add.isPending ? "Connecting…" : "Add MCP server" }) }) }));
124
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentAccessOperation, AppPermissionOutcome, ComputerAccessOperation } from "@fungi.computer/agar";
2
+ import type { AppAgentPermission, AppComputerPermission } from "@fungi.computer/oyster/shell/app-directory";
3
+ export declare const consentableAgentOperations: (requested: readonly AgentAccessOperation[], declared: readonly string[]) => readonly AgentAccessOperation[];
4
+ export declare const consentableComputerOperations: (requested: readonly ComputerAccessOperation[], declared: readonly string[]) => readonly ComputerAccessOperation[];
5
+ /** The all-Team-Agents grant that adds `offered` to the current policy. */
6
+ export declare function consentedAgentPermission(current: AppAgentPermission, offered: readonly AgentAccessOperation[]): AppAgentPermission | "covered" | "customize";
7
+ /**
8
+ * The all-Team-Computers grant that adds `offered`. An empty selection is no
9
+ * access; a real Computer selection is the owner's choice and never widened.
10
+ */
11
+ export declare function consentedComputerPermission(current: AppComputerPermission | null, offered: readonly ComputerAccessOperation[]): AppComputerPermission | "covered" | "customize";
12
+ export type AppAccessRequest = Readonly<{
13
+ appName: string;
14
+ agents: readonly AgentAccessOperation[];
15
+ computers: readonly ComputerAccessOperation[];
16
+ /** Save the grant through the owner's own authority; resolves false when it failed. */
17
+ allow: () => Promise<boolean>;
18
+ settle: (outcome: AppPermissionOutcome) => void;
19
+ }>;
20
+ /** One-click host consent for the rights a default App declared. */
21
+ export declare function AppAccessConsent({ request, }: {
22
+ request: AppAccessRequest | null;
23
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,122 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from "@fungi.computer/caps/components/button";
3
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle, } from "@fungi.computer/caps/components/dialog";
4
+ import { WindowFrame, WindowHeader, } from "@fungi.computer/caps/components/window";
5
+ import { useState } from "react";
6
+ const directoryOperations = ["agent.list"];
7
+ const sessionOperations = ["agent.read", "agent.send", "agent.manage"];
8
+ const agentOperations = [
9
+ "agent.list",
10
+ "agent.manage",
11
+ "agent.read",
12
+ "agent.send",
13
+ ];
14
+ const computerOperations = [
15
+ "computer.list",
16
+ "computer.readFile",
17
+ "computer.writeFile",
18
+ "computer.execute",
19
+ ];
20
+ /** What the trusted host may offer: the request bounded by the declared ceiling. */
21
+ function consentableOperations(known, requested, declared) {
22
+ return known.filter((operation) => requested.includes(operation) && declared.includes(operation));
23
+ }
24
+ export const consentableAgentOperations = (requested, declared) => consentableOperations(agentOperations, requested, declared);
25
+ export const consentableComputerOperations = (requested, declared) => consentableOperations(computerOperations, requested, declared);
26
+ /** The all-Team-Agents grant that adds `offered` to the current policy. */
27
+ export function consentedAgentPermission(current, offered) {
28
+ if (current.kind === "selected-agents")
29
+ return "customize";
30
+ const currentOperations = [
31
+ ...current.directoryOperations,
32
+ ...current.sessionOperations,
33
+ ];
34
+ if (offered.every((operation) => currentOperations.includes(operation)))
35
+ return "covered";
36
+ const granted = new Set([...currentOperations, ...offered]);
37
+ return {
38
+ kind: "all-team-agents",
39
+ directoryOperations: directoryOperations.filter((operation) => granted.has(operation)),
40
+ sessionOperations: sessionOperations.filter((operation) => granted.has(operation)),
41
+ };
42
+ }
43
+ /**
44
+ * The all-Team-Computers grant that adds `offered`. An empty selection is no
45
+ * access; a real Computer selection is the owner's choice and never widened.
46
+ */
47
+ export function consentedComputerPermission(current, offered) {
48
+ if (current?.kind === "selected-computers" && current.computers.length > 0)
49
+ return "customize";
50
+ const currentOperations = current?.kind === "all-team-computers" ? current.operations : [];
51
+ if (offered.every((operation) => currentOperations.includes(operation)))
52
+ return "covered";
53
+ const granted = new Set([...currentOperations, ...offered]);
54
+ return {
55
+ kind: "all-team-computers",
56
+ operations: computerOperations.filter((operation) => granted.has(operation)),
57
+ };
58
+ }
59
+ /** Sentence variants following "<App> can" in the consent description. */
60
+ const rightNames = {
61
+ "agent.list": "see your Agents",
62
+ "agent.manage": "create and manage Agents and their sessions",
63
+ "agent.read": "read their conversations",
64
+ "agent.send": "send messages",
65
+ "computer.list": "see your Computers",
66
+ "computer.readFile": "read their files",
67
+ "computer.writeFile": "change their files",
68
+ "computer.execute": "run commands on them",
69
+ };
70
+ const describe = (offered) => {
71
+ const rights = [...agentOperations, ...computerOperations]
72
+ .filter((operation) => offered.includes(operation))
73
+ .map((operation) => rightNames[operation]);
74
+ const last = rights.at(-1) ?? "";
75
+ return rights.length < 2
76
+ ? last
77
+ : `${rights.slice(0, -1).join(", ")} and ${last}`;
78
+ };
79
+ const resources = (request, agent, computer) => [
80
+ request.agents.length > 0 ? agent : null,
81
+ request.computers.length > 0 ? computer : null,
82
+ ]
83
+ .filter((resource) => resource !== null)
84
+ .join(" and ");
85
+ /** One-click host consent for the rights a default App declared. */
86
+ export function AppAccessConsent({ request, }) {
87
+ const [saving, setSaving] = useState(false);
88
+ const [failed, setFailed] = useState(false);
89
+ const settle = (outcome) => {
90
+ setSaving(false);
91
+ setFailed(false);
92
+ request?.settle(outcome);
93
+ };
94
+ return (_jsx(Dialog, { open: request !== null, onOpenChange: (next) => {
95
+ if (!next && !saving)
96
+ settle("declined");
97
+ }, children: _jsxs(DialogContent, { render: _jsx(WindowFrame, { floating: true }), keepMounted: false, className: "tourtois-app-consent", children: [_jsx(WindowHeader, { title: "App access" }), _jsxs("div", { className: "tourtois-app-consent-body", children: [_jsx(DialogTitle, { children: request
98
+ ? `Let ${request.appName} work with your ${resources(request, "Agents", "Computers")}?`
99
+ : "" }), _jsx(DialogDescription, { children: request
100
+ ? `${request.appName} can ${describe([...request.agents, ...request.computers])} for every ${resources(request, "Agent", "Computer")} in this Oyster. You can change this later in System Settings.`
101
+ : "" }), failed ? (_jsx("p", { role: "alert", children: "Access could not be saved. Try again." })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "ghost", disabled: saving, onClick: () => {
102
+ settle("customize");
103
+ }, children: "Customize\u2026" }), _jsx(Button, { variant: "ghost", disabled: saving, onClick: () => {
104
+ settle("declined");
105
+ }, children: "Not now" }), _jsx(Button, { disabled: saving || request === null, onClick: () => {
106
+ if (request === null)
107
+ return;
108
+ setSaving(true);
109
+ setFailed(false);
110
+ void request.allow().then((saved) => {
111
+ if (saved)
112
+ settle("granted");
113
+ else {
114
+ setSaving(false);
115
+ setFailed(true);
116
+ }
117
+ }, () => {
118
+ setSaving(false);
119
+ setFailed(true);
120
+ });
121
+ }, children: saving ? "Allowing…" : "Allow" })] })] })] }) }));
122
+ }
@@ -0,0 +1,9 @@
1
+ import type { AppDeclaration } from "@fungi.computer/puffball/app";
2
+ /** The declared deputy ceiling, shown identically on both hosts; this grants nothing. */
3
+ export declare function AppCallPermissions({ calls, apps, }: {
4
+ calls: AppDeclaration["calls"];
5
+ apps: readonly {
6
+ appId: string;
7
+ displayName?: string | undefined;
8
+ }[];
9
+ }): React.JSX.Element | null;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DataList, DataListHeading, DataRow, } from "@fungi.computer/caps/components/data-list";
3
+ const methodLabels = new Map([
4
+ ["files.tree", "browse files"],
5
+ ["files.list", "list files"],
6
+ ["files.read", "read files"],
7
+ ["commits.list", "read commits"],
8
+ ["rebuildCopies", "refresh file views"],
9
+ ["files.save", "save files"],
10
+ ["files.sync", "sync files"],
11
+ ["files.discard", "discard edits"],
12
+ ]);
13
+ /** The declared deputy ceiling, shown identically on both hosts; this grants nothing. */
14
+ export function AppCallPermissions({ calls, apps, }) {
15
+ if (!calls?.length)
16
+ return null;
17
+ return (_jsxs("section", { "aria-label": "Calls to other Apps", children: [_jsx(DataListHeading, { level: 3, children: "Other Apps" }), _jsx(DataList, { density: "compact", children: calls.map((call) => (_jsx(DataRow, { children: _jsxs("span", { children: ["Can use", " ", apps.find((app) => app.appId === call.appId)?.displayName ??
18
+ call.appId, ":", " ", call.methods
19
+ .map((method) => methodLabels.get(method) ?? method)
20
+ .join(", ")] }) }, call.appId))) }), _jsx("p", { className: "mt-1 text-xs", children: "Calls use your permissions in the other App. Renamed target methods need approval again." })] }));
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { WorkbenchDesktopSettings } from "@fungi.computer/oyster/shell/workbench";
2
+ import type { AppDirectoryEntry, AppDirectoryGill } from "@fungi.computer/oyster/shell/app-directory";
3
+ export type AppDesktopSettingsApp = Pick<AppDirectoryEntry, "surfaceId" | "availability"> & Readonly<{
4
+ gills: readonly AppDirectoryGill[];
5
+ }>;
6
+ /** Suggestions only: the member chooses, then the admitted dashboard arranges. */
7
+ export declare function AppDesktopSettings({ app, desktop, consentReadyAt, }: {
8
+ app: AppDesktopSettingsApp;
9
+ desktop: WorkbenchDesktopSettings;
10
+ consentReadyAt?: number;
11
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,102 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DataListHeading } from "@fungi.computer/caps/components/data-list";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { AppError } from "@fungi.computer/agar";
5
+ import { Button } from "@fungi.computer/caps/components/button";
6
+ import { DataList, DataRow } from "@fungi.computer/caps/components/data-list";
7
+ import { Select } from "@fungi.computer/caps/components/select";
8
+ const sizeLabels = { S: "Small", M: "Medium", L: "Large" };
9
+ function GillOption({ app, gill, desktop, consentReadyAt, }) {
10
+ const sizes = gill.desktop?.sizes ?? [];
11
+ const [size, setSize] = useState(sizes[0] ?? "S");
12
+ const [saved, setSaved] = useState(null);
13
+ const [pending, setPending] = useState(false);
14
+ const busy = useRef(false);
15
+ const [consentReady, setConsentReady] = useState(Date.now() >= consentReadyAt);
16
+ useEffect(() => {
17
+ setConsentReady(Date.now() >= consentReadyAt);
18
+ const timer = window.setTimeout(() => {
19
+ setConsentReady(true);
20
+ }, Math.max(0, consentReadyAt - Date.now()));
21
+ return () => {
22
+ window.clearTimeout(timer);
23
+ };
24
+ }, [consentReadyAt]);
25
+ const [error, setError] = useState(null);
26
+ useEffect(() => {
27
+ let retired = false;
28
+ setSaved(null);
29
+ void Promise.resolve()
30
+ .then(() => desktop.readGill({ surfaceId: app.surfaceId, gillId: gill.id }))
31
+ .then((value) => {
32
+ if (!retired)
33
+ setSaved(value);
34
+ })
35
+ .catch((cause) => {
36
+ console.error("Desktop Gill saved state could not be read", {
37
+ surfaceId: app.surfaceId,
38
+ gillId: gill.id,
39
+ cause,
40
+ });
41
+ if (!retired)
42
+ setError("Your desktop could not be read. Reopen Settings to try again.");
43
+ return { kind: "failed", cause };
44
+ });
45
+ return () => {
46
+ retired = true;
47
+ };
48
+ }, [app.surfaceId, gill.id, desktop]);
49
+ const onDesktop = !!saved?.length;
50
+ const change = async () => {
51
+ if (busy.current || saved === null || Date.now() < consentReadyAt)
52
+ return { kind: "busy" };
53
+ busy.current = true;
54
+ setPending(true);
55
+ setError(null);
56
+ const selection = { surfaceId: app.surfaceId, gillId: gill.id };
57
+ try {
58
+ if (onDesktop)
59
+ await desktop.removeGill(selection);
60
+ else
61
+ await desktop.addGill({ ...selection, size });
62
+ setSaved(await desktop.readGill(selection));
63
+ return { kind: "confirmed" };
64
+ }
65
+ catch (cause) {
66
+ console.error("Desktop Gill member change failed", {
67
+ ...selection,
68
+ cause,
69
+ });
70
+ setError(cause instanceof AppError
71
+ ? cause.message
72
+ : "The widget could not be changed. Reopen Settings to try again.");
73
+ // A refusal may mean another tab changed the saved layout. Never keep an optimistic membership flag.
74
+ setSaved(null);
75
+ return { kind: "failed", cause };
76
+ }
77
+ finally {
78
+ busy.current = false;
79
+ setPending(false);
80
+ }
81
+ };
82
+ return (_jsxs(DataRow, { style: { gridTemplateColumns: "minmax(0, 1fr)" }, children: [_jsxs("div", { children: [_jsx("strong", { children: gill.title }), _jsxs("p", { children: [saved === null && !error && "Reading your desktop…", saved !== null && onDesktop && (_jsxs("span", { role: "status", children: ["On your desktop \u00B7", " ", saved.map((value) => sizeLabels[value]).join(", ")] })), saved !== null &&
83
+ !onDesktop &&
84
+ "Choose a size, then add this widget to your desktop.", error ? _jsx("span", { role: "alert", children: error }) : null] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", style: { gridRow: 2, gridColumn: "1 / -1" }, children: [!onDesktop ? (_jsx("div", { style: { width: "7rem", flex: "0 0 auto" }, children: _jsx(Select, { "aria-label": `Size for ${gill.title}`, value: size, disabled: pending || saved === null || !consentReady, onChange: (event) => {
85
+ const next = sizes.find((value) => value === event.currentTarget.value);
86
+ if (next)
87
+ setSize(next);
88
+ }, children: sizes.map((value) => (_jsx("option", { value: value, children: sizeLabels[value] }, value))) }) })) : null, _jsx(Button, { size: "sm", variant: "secondary", loading: pending, disabled: pending || saved === null || !consentReady, onClick: (event) => {
89
+ if (event.detail > 1)
90
+ return;
91
+ void change();
92
+ }, children: onDesktop ? "Remove from desktop" : "Add to desktop" })] })] }));
93
+ }
94
+ /** Suggestions only: the member chooses, then the admitted dashboard arranges. */
95
+ export function AppDesktopSettings({ app, desktop, consentReadyAt = 0, }) {
96
+ const gills = app.availability === "available"
97
+ ? app.gills.filter((gill) => gill.desktop)
98
+ : [];
99
+ return (_jsxs("section", { "aria-label": "Desktop widgets", children: [_jsx(DataListHeading, { level: 3, children: "Desktop widgets" }), _jsx("p", { children: "Add this App\u2019s widgets to your personal desktop. Your Team default stays unchanged." }), gills.length ? (_jsx(DataList, { "aria-label": "Available desktop widgets", children: gills.map((gill) => (_jsx(GillOption, { app: app, gill: gill, desktop: desktop, consentReadyAt: consentReadyAt }, gill.id))) })) : (_jsx("p", { children: app.availability === "available"
100
+ ? "This App offers no desktop widgets."
101
+ : "This App is unavailable. Its widgets cannot be added." }))] }));
102
+ }
@@ -0,0 +1,37 @@
1
+ import { type AppStatusAction } from "@fungi.computer/agar";
2
+ import { createAppWindowManager, type AppWindowProjection } from "./app-windows.js";
3
+ import { type AppDirectoryApp } from "./app-launch.js";
4
+ /** The focused window's App-level settings; its Computer lives in its status bar. */
5
+ export declare function FocusedWindowControls({ app, onOpenSettings, onOpenPermissions, projection, }: {
6
+ app: AppDirectoryApp | undefined;
7
+ onOpenSettings: () => void;
8
+ onOpenPermissions: () => void;
9
+ projection: AppWindowProjection;
10
+ }): import("react/jsx-runtime").JSX.Element | null;
11
+ export declare function AppPane({ projection, icon, manager, onError, index, focusWindow, afterHide, onBack, onSelectComputer, }: {
12
+ projection: AppWindowProjection;
13
+ icon: string | null | undefined;
14
+ manager: ReturnType<typeof createAppWindowManager>;
15
+ onError: (message: string) => void;
16
+ index: number;
17
+ focusWindow: (id: string) => void;
18
+ afterHide: (id: string) => void;
19
+ onBack: (id: string) => void;
20
+ /** The person chose another of this window's offered Computers. */
21
+ onSelectComputer: (windowId: string, computerId: string) => void;
22
+ }): import("react/jsx-runtime").JSX.Element;
23
+ /**
24
+ * One window's status bar, owned by the shell like its title bar: the
25
+ * window's Computer first (only the person changes it, here), then the items
26
+ * the App declared as data. The shell renders every item with Caps pieces and
27
+ * hands the person's use back to the App. A window with neither has no bar.
28
+ */
29
+ export declare function WindowStatusBar({ projection, onSelectComputer, onAction, surface, computerPickerOpen, onComputerPickerOpenChange, }: {
30
+ projection: AppWindowProjection;
31
+ onSelectComputer: (computerId: string) => void;
32
+ onAction: (action: AppStatusAction) => void;
33
+ /** The App's own surface, which UI opened from the bar is placed against. */
34
+ surface?: () => HTMLElement | null;
35
+ computerPickerOpen?: boolean;
36
+ onComputerPickerOpenChange?: (open: boolean) => void;
37
+ }): import("react/jsx-runtime").JSX.Element | null;