copilotz 1.0.0-rc.1 → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 1.0.0-rc.2 — 2026-10-30
2
+
3
+ - **Orgs:** an org's socket is in its own address (`/o/<org>/oxian`), beside the shared `/oxian`, so
4
+ a browser that sends no cookies with a WebSocket (Safari) reaches its org. The web host's page
5
+ says which socket to use (`socket`) and its base (`base`).
6
+ - **Web:** a person shows as their name, from their profile, not as their id: the viewer has a
7
+ `label` beside its `name`, which the shell's account menu and the chat's greeting use.
8
+ - **Computers:** one machine serves many apps, or many orgs of one. Each is a computer of its own
9
+ there, with its own id and secret, all in one program; a state kept before becomes its one
10
+ connection. `computer list` says which and where; `computer disconnect <app-url>` leaves one. An
11
+ org's spaces go in a folder named for the org (`~/Copilotz/acme-studio`, `~/Copilotz/personal`): a
12
+ code made on an org says it (`folder`), and the install lines pass it on (`--folder`).
13
+ - **The computer program's release:** each release on GitHub has it, made by the publish workflow on
14
+ a Mac. When the program is the same as the last release's (`source` in its manifest), the release
15
+ has that one's binaries, version and all, so computers don't download what they have.
16
+ `computerDownloads()` serves this Copilotz's own release by default.
17
+
1
18
  ## 1.0.0-rc.1 — 2026-10-30
2
19
 
3
20
  Copilotz 1.0 is a new runtime, written from scratch: one runtime for agents and the apps around
package/README.md CHANGED
@@ -22,7 +22,7 @@ bun add copilotz # Bun
22
22
  deno add jsr:@copilotz/copilotz # Deno (or `deno add npm:copilotz`)
23
23
  ```
24
24
 
25
- On Deno, name it `copilotz` in `deno.json` (`"copilotz": "jsr:@copilotz/copilotz@^1.0.0-rc.1"`), and
25
+ On Deno, name it `copilotz` in `deno.json` (`"copilotz": "jsr:@copilotz/copilotz@^1.0.0-rc.2"`), and
26
26
  the imports below are the same everywhere. Some pieces need a package of their own, installed beside
27
27
  it: `esbuild` to bundle the web host on Node, `@electric-sql/pglite` for PostgreSQL in the process,
28
28
  and `@types/react` for the front ends' types.
@@ -196,18 +196,26 @@ each socket to the runtime of the org its request is for:
196
196
  ## Installing a computer
197
197
 
198
198
  People's computers run the computer program (`plugins/compute/computers/program/`), a single
199
- executable for macOS, Linux and Windows that an app serves from its own origin once it's built:
200
-
201
- ```sh
202
- node scripts/computer/build.ts # dist/computer/<version>/: a binary per platform (by Bun),
203
- # install.sh, install.ps1, manifest.json (sizes, SHA-256)
204
- ```
199
+ executable for macOS, Linux and Windows. Each Copilotz release on GitHub has it (`v<version>`: a
200
+ binary per platform, `install.sh`, `install.ps1`, and `manifest.json` with their sizes and SHA-256),
201
+ and an app serves it from there by default:
205
202
 
206
203
  ```ts
207
204
  import { computerDownloads } from "copilotz/plugins/compute/downloads";
208
205
 
209
- plugins: [...plugins, await computerDownloads({ dir: "dist/computer/1.0.0-rc.1" })], // /computer/download/
210
- plugins: [...plugins, await computerDownloads({ url: "https://…/releases/download/…/" })], // or elsewhere
206
+ plugins: [...plugins, await computerDownloads()], // this Copilotz's release
207
+ plugins: [...plugins, await computerDownloads({ dir: "dist/computer/1.0.0-rc.2" })], // its own: /computer/download/
208
+ plugins: [...plugins, await computerDownloads({ url: "https://downloads.example.com/computer/" })], // or elsewhere
209
+ ```
210
+
211
+ The publish workflow makes it when a `v1.*` tag is pushed, on a Mac (the macOS binaries are signed
212
+ there). A release whose program is the same as the last one's (`source` in the manifest: a hash of
213
+ its code, without its version, its install scripts and the Bun that builds it) has that release's
214
+ binaries, version and all, so computers don't update to what they have. To build one yourself:
215
+
216
+ ```sh
217
+ node scripts/computer/build.ts # dist/computer/<version>/
218
+ node scripts/computer/build.ts --reuse <url> # that release's, when it's the same program
211
219
  ```
212
220
 
213
221
  A space's Computer tab (or `/computer connect`) then gives the one line to run on the machine, for
@@ -232,6 +240,11 @@ and its binary's URL, size and SHA-256; it checks them, and that the new one run
232
240
  starts again. The binaries aren't signed yet, so macOS and Windows warn about one downloaded with a
233
241
  browser; the line doesn't need that.
234
242
 
243
+ One machine can serve several apps, or several orgs of one: run the line from each, and each is a
244
+ computer of its own there, in one program. Each one's spaces go in a folder of its own under
245
+ `~/Copilotz`, named for the org (the line from an org says it: `--folder acme`), else for the app's
246
+ host. `computer list` says which and where; `computer disconnect <app-url>` leaves one.
247
+
235
248
  ## Plugins
236
249
 
237
250
  Plugins come in themes. Every plugin is a plain object, and its settings are a resource under its
@@ -19,7 +19,9 @@
19
19
  * - `POST /signout`: out of the directory and every org.
20
20
  * - `/o/<org>/…`: that org, by its address, for what reaches it without a
21
21
  * browser's session: its API (a key), its webhooks (signed), its
22
- * computers' socket and downloads. The org's own handlers say who may.
22
+ * computers' downloads. The org's own handlers say who may.
23
+ * - `/o/<org>/oxian`: that org's socket, for its pages and its computers
24
+ * (a ticket or a computer's credentials say who); no cookie needed.
23
25
  * - Staff's console: `GET /console/orgs`; `POST /console/orgs/<id>`
24
26
  * `{ suspended?, name? }`; `POST …/<id>/move` `{ schema }` (its rows to
25
27
  * another schema); `GET …/<id>/export` (its rows, as SQL); `POST
@@ -30,7 +32,7 @@
30
32
  * its tenant), its plugins with the host's `here` (the org's name, and how
31
33
  * it reaches the directory), and `admit` for its browsers.
32
34
  */
33
- import type { Actor, Plugins, Runtime } from "../../runtime/mod.js";
35
+ import type { Actor, Plugins, Runtime, Transport } from "../../runtime/mod.js";
34
36
  import { type Code, type WebServerOptions } from "../web/server.js";
35
37
  /** An org as the host opens it. */
36
38
  export type OrgInfo = {
@@ -44,10 +46,15 @@ export type OrgInfo = {
44
46
  export type OrgsHostOptions = Pick<WebServerOptions, "title" | "theme" | "google" | "dev" | "assets"> & {
45
47
  /** The directory's runtime: identity, a sign-in method, and the directory plugin. */
46
48
  directory: Runtime;
47
- /** Opens an org: the app's runtime for it, with these plugins too and this `admit`. */
49
+ /**
50
+ * Opens an org: the app's runtime for it, with these plugins too, this
51
+ * `admit`, and these `transports` (its socket, at `/oxian` and at its
52
+ * address, `/o/<org>/oxian`).
53
+ */
48
54
  open(org: OrgInfo, given: {
49
55
  plugins: Plugins;
50
56
  admit: (ticket: string) => Actor | undefined;
57
+ transports: Transport[];
51
58
  }): Promise<Runtime>;
52
59
  /** The socket upgrade for an org's runtime (its transport for browsers). */
53
60
  upgrade(runtime: Runtime, request: Request): Response | Promise<Response>;
package/hosts/orgs/mod.js CHANGED
@@ -1,12 +1,18 @@
1
1
  import { definePlugin } from "../../runtime/mod.js";
2
2
  import { createTickets } from "../web/tickets.js";
3
- import { cookieOf, setCookie, webServer } from "../web/server.js";
3
+ import { cookieOf, setCookie, socket, webServer, } from "../web/server.js";
4
4
  const DIRECTORY = "dir";
5
5
  const CURRENT = "org";
6
6
  const MONTH = 30 * 86_400;
7
7
  const sessionOf = (org) => `s_${org}`;
8
8
  /** Where an org is reached by its address: `/o/<org>`. */
9
9
  const baseOf = (org) => `/o/${encodeURIComponent(org)}`;
10
+ /**
11
+ * Where a page's or a computer's socket reaches an org: under its address
12
+ * (`/o/<org>/oxian`), which names it without cookies (Safari sends none with
13
+ * a socket). Its runtime takes sockets there and at `/oxian` (`given.transports`).
14
+ */
15
+ const socketOf = (org) => `${baseOf(org)}${socket.config.path}`;
10
16
  /** The org a path addresses (`/o/<org>/…`), and the path within it. */
11
17
  const addressed = (path) => {
12
18
  const found = /^\/o\/([^/]+)(\/.*)?$/.exec(path);
@@ -54,7 +60,11 @@ export function orgsHost(options) {
54
60
  if (!cell) {
55
61
  cell = (async () => {
56
62
  const tickets = createTickets();
57
- const runtime = await options.open(org, { plugins: [here(org)], admit: tickets.admit });
63
+ const runtime = await options.open(org, {
64
+ plugins: [here(org)],
65
+ admit: tickets.admit,
66
+ transports: [socket, { type: "websocket", config: { path: socketOf(org.id) } }],
67
+ });
58
68
  const serve = webServer({
59
69
  runtime,
60
70
  tickets,
@@ -68,6 +78,7 @@ export function orgsHost(options) {
68
78
  cookie: sessionOf(org.id),
69
79
  signIn: false,
70
80
  base: baseOf(org.id),
81
+ socket: socketOf(org.id),
71
82
  });
72
83
  const made = { org, runtime, tickets, serve, last: Date.now() };
73
84
  live.set(org.id, made);
@@ -293,6 +304,9 @@ export function orgsHost(options) {
293
304
  return json({ error: "This org is suspended." }, { status: 403 });
294
305
  const cell = await cellOf(org);
295
306
  cell.last = Date.now();
307
+ // Its socket, as it came: its runtime takes sockets at this address too.
308
+ if (at.path === socket.config.path)
309
+ return await options.upgrade(cell.runtime, request);
296
310
  const inner = new URL(url);
297
311
  inner.pathname = at.path;
298
312
  return await cell.serve(new Request(inner, request));
@@ -475,12 +489,12 @@ export function orgsHost(options) {
475
489
  * before its socket connects; one not open yet is a retry for the client.
476
490
  */
477
491
  openRuntime(request) {
478
- const at = addressed(new URL(request.url).pathname);
479
- const org = at?.org ?? cookieOf(request, CURRENT);
492
+ const named = addressed(new URL(request.url).pathname)?.org;
493
+ const org = named ?? cookieOf(request, CURRENT);
480
494
  const cell = org ? live.get(org) : undefined;
481
- // An org addressed but closed (a computer connecting first) opens for its retry.
482
- if (!cell && at) {
483
- void orgInfo(at.org).then((info) => {
495
+ // An org named but closed (a computer connecting first) opens for its retry.
496
+ if (!cell && named) {
497
+ void orgInfo(named).then((info) => {
484
498
  if (info && !info.suspended)
485
499
  void cellOf(info).catch(() => { });
486
500
  }).catch(() => { });
package/hosts/web/host.js CHANGED
@@ -96,7 +96,7 @@ export async function web(options) {
96
96
  void loadOrgs();
97
97
  let fresh = first.ticket;
98
98
  const url = options.url ??
99
- `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/oxian`;
99
+ `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${config().socket ?? "/oxian"}`;
100
100
  const client = connect({
101
101
  transport: { type: "websocket", config: { url, allowInsecureLoopback: true } },
102
102
  // The first connection takes the ticket in hand; a new one asks again.
@@ -113,7 +113,12 @@ export async function web(options) {
113
113
  plugins: options.plugins,
114
114
  });
115
115
  await client.ready;
116
- const viewer = { kind: "person", name: first.person };
116
+ // Who they are (their id), and what they're shown as (their name).
117
+ const viewer = {
118
+ kind: "person",
119
+ name: first.person,
120
+ ...(first.name ? { label: first.name } : {}),
121
+ };
117
122
  session.client = client;
118
123
  session.viewer = viewer;
119
124
  let space = (await options.start?.(client, first.person))?.space;
@@ -20,7 +20,9 @@ export async function serve(options) {
20
20
  export async function listen(options) {
21
21
  const { hostname = "127.0.0.1", port = 0, signal } = options;
22
22
  const prepare = (request) => {
23
- const to = new URL(request.url).pathname === socket.config.path
23
+ // The socket's path, or a runtime's own under it (an org's: `/o/<org>/oxian`).
24
+ const path = new URL(request.url).pathname;
25
+ const to = path === socket.config.path || path.endsWith(socket.config.path)
24
26
  ? options.hypervisor(request)
25
27
  : undefined;
26
28
  return to ? to.prepare(request) : { kind: "response", response: options.fetch(request) };
@@ -76,6 +76,13 @@ export type WebServerOptions = {
76
76
  * The page tells its code (`appBase()`), so the addresses it shows work.
77
77
  */
78
78
  base?: string;
79
+ /**
80
+ * Where the page's socket connects, when it isn't the runtime's own
81
+ * `/oxian`: a host serving many orgs gives each org its address there
82
+ * (`/o/<org>/oxian`), so the socket finds it without cookies, which Safari
83
+ * doesn't send with a socket.
84
+ */
85
+ socket?: string;
79
86
  /**
80
87
  * The app's theme, as its front ends give it (`themeOf(plugins)`, of those
81
88
  * with a `theme` resource): the page carries it for its first paint.
@@ -103,6 +103,7 @@ JSON.stringify({
103
103
  google: options.google ?? null,
104
104
  dev: options.dev ?? false,
105
105
  ...(options.base ? { base: options.base } : {}),
106
+ ...(options.socket ? { socket: options.socket } : {}),
106
107
  })
107
108
  .replaceAll("<", "\\u003c")}</script>
108
109
  </head>
@@ -189,7 +190,18 @@ export function webServer(options) {
189
190
  const actor = await whom(request);
190
191
  if (!actor)
191
192
  return json({ error: "Not signed in" }, { status: 401 });
192
- return json({ ticket: tickets.issue(actor), person: actor.id });
193
+ // What the page shows them as: their name as they signed in, else their email.
194
+ const profile = actor.kind === "person"
195
+ ? await runtime.query("person.profile", {
196
+ person: actor.id,
197
+ }).catch(() => null)
198
+ : null;
199
+ const name = profile?.name ?? profile?.email;
200
+ return json({
201
+ ticket: tickets.issue(actor),
202
+ person: actor.id,
203
+ ...(name ? { name } : {}),
204
+ });
193
205
  }
194
206
  case "POST /signin/google": {
195
207
  if (!options.google || !signIn)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilotz",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.2",
4
4
  "description": "Copilotz: one runtime for Node, Deno, Bun, Cloudflare Workers and the browser.",
5
5
  "keywords": [
6
6
  "agents",
@@ -19,7 +19,7 @@ export const homeView = (_, ctx) => {
19
19
  const to = String(ctx.hints.query?.to ?? "");
20
20
  const suggestions = Object.values((ctx.resources.suggestions ?? {}))
21
21
  .filter((s) => typeof s === "string");
22
- const name = greeted(ctx.viewer?.name ?? "");
22
+ const name = greeted(ctx.viewer?.label ?? ctx.viewer?.name ?? "");
23
23
  const shared = space && space.kind !== "personal";
24
24
  // What other front ends show over the greeting.
25
25
  const tops = Object.values((ctx.resources.home ?? {}))
@@ -8,7 +8,8 @@ export declare const byAgent: (m: Message) => boolean;
8
8
  /** Names as a sentence says them: "ana", "ana and bob", "ana, bob and cy". */
9
9
  export declare const names: (list: string[]) => string;
10
10
  /**
11
- * What home greets someone as: their name, capitalised; signed in by email,
12
- * its first part, up to a dot ("ana.lima@example.com" is "Ana").
11
+ * What home greets someone as: their first name, capitalised; signed in by
12
+ * email, its first part, up to a dot ("ana.lima@example.com" is "Ana"). An
13
+ * id that isn't a name ("google:1234") greets no one by name.
13
14
  */
14
15
  export declare const greeted: (name: string) => string;
@@ -3,10 +3,13 @@ export const byAgent = (m) => m.turn !== undefined || (m.hops ?? 0) > 0 || m.too
3
3
  /** Names as a sentence says them: "ana", "ana and bob", "ana, bob and cy". */
4
4
  export const names = (list) => list.length < 2 ? list.join("") : `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
5
5
  /**
6
- * What home greets someone as: their name, capitalised; signed in by email,
7
- * its first part, up to a dot ("ana.lima@example.com" is "Ana").
6
+ * What home greets someone as: their first name, capitalised; signed in by
7
+ * email, its first part, up to a dot ("ana.lima@example.com" is "Ana"). An
8
+ * id that isn't a name ("google:1234") greets no one by name.
8
9
  */
9
10
  export const greeted = (name) => {
10
- const first = name.split("@")[0].split(/[._+-]/).find(Boolean) ?? "";
11
+ if (/^[a-z]+:/.test(name))
12
+ return "";
13
+ const first = name.split("@")[0].split(/[\s._+-]/).find(Boolean) ?? "";
11
14
  return first ? first[0].toLocaleUpperCase() + first.slice(1) : "";
12
15
  };
@@ -5,10 +5,13 @@
5
5
  * which is them saying yes. It works once, for minutes. With it, where the
6
6
  * computer connects, when the app says (`computers.url`), and where the
7
7
  * program is downloaded from, when it says (`downloads`: the release's base,
8
- * on the app's origin unless absolute, its version and binaries).
8
+ * on the app's origin unless absolute, its version and binaries). On an org,
9
+ * the folder its spaces go in on that computer (`folder`): the org's name,
10
+ * plain, `personal` for one's own (it's named for them).
9
11
  */
10
12
  import { defineAction } from "../../../../runtime/mod.js";
11
13
  import { mustBeAble } from "../shared/desks.js";
14
+ import { plainName } from "../shared/folder.js";
12
15
  import { code, hash } from "../shared/secret.js";
13
16
  import { settingsOf } from "../shared/settings.js";
14
17
  export const codeAction = defineAction({
@@ -33,11 +36,14 @@ export const codeAction = defineAction({
33
36
  in: space,
34
37
  });
35
38
  const downloads = ctx.resources.downloads;
39
+ const org = ctx.resources.org;
40
+ const folder = org?.kind === "personal" ? "personal" : org?.name && plainName(org.name, "");
36
41
  return {
37
42
  code: made,
38
43
  expires,
39
44
  in: space,
40
45
  ...(url ? { url } : {}),
46
+ ...(folder ? { folder } : {}),
41
47
  ...(downloads ? { downloads: { base: downloads.base, ...downloads.manifest } } : {}),
42
48
  };
43
49
  },
@@ -1,10 +1,3 @@
1
- /**
2
- * A release of the computer program, as `scripts/computer/build.ts` makes it
3
- * and an app serves it (the computers plugin's `downloads`): `install.sh` and
4
- * `install.ps1`, a binary per platform, and `manifest.json`, which says the
5
- * version and each binary's file, size and SHA-256. Plain data and functions
6
- * only: the build, the app, its web front end and the program all read it.
7
- */
8
1
  /**
9
2
  * The work an app offers a computer a newer release with (`computer.update`;
10
3
  * the computers plugin's `computer` workers serve it beside `WORK`'s): the
@@ -33,9 +26,14 @@ export type Binary = Platform & {
33
26
  size: number;
34
27
  sha256: string;
35
28
  };
36
- /** What `manifest.json` says. */
29
+ /**
30
+ * What `manifest.json` says: the program's version, its binaries, and what
31
+ * the program is (`source`: a SHA-256 of what it's built from, its version
32
+ * left out; the same program, the same `source`).
33
+ */
37
34
  export type Manifest = {
38
35
  version: string;
36
+ source?: string;
39
37
  binaries: Binary[];
40
38
  };
41
39
  /**
@@ -74,12 +72,14 @@ export declare const binaryFor: (manifest: Manifest, { os, arch }: Platform) =>
74
72
  /**
75
73
  * The one line that installs the program and connects it with `code`: on
76
74
  * macOS and Linux (`sh`), or in Windows' PowerShell. `base` is where the
77
- * release is (absolute), named only when it isn't the app's own.
75
+ * release is (absolute), named only when it isn't the app's own; `folder`,
76
+ * the folder its spaces go in (its org's name), plain, so the line needs no quoting.
78
77
  */
79
- export declare function installCommand(shell: "sh" | "powershell", { app, code, base }: {
78
+ export declare function installCommand(shell: "sh" | "powershell", { app, code, base, folder }: {
80
79
  app: string;
81
80
  code: string;
82
81
  base: string;
82
+ folder?: string;
83
83
  }): string;
84
84
  /** Whether version `a` is newer than `b`, as semver orders them (`1.0.0-rc.2` < `1.0.0`). */
85
85
  export declare function newer(a: string, b: string): boolean;
@@ -5,6 +5,7 @@
5
5
  * version and each binary's file, size and SHA-256. Plain data and functions
6
6
  * only: the build, the app, its web front end and the program all read it.
7
7
  */
8
+ import { plainName } from "../shared/folder.js";
8
9
  /**
9
10
  * The work an app offers a computer a newer release with (`computer.update`;
10
11
  * the computers plugin's `computer` workers serve it beside `WORK`'s): the
@@ -42,13 +43,15 @@ export const binaryFor = (manifest, { os, arch }) => manifest.binaries.find((bin
42
43
  /**
43
44
  * The one line that installs the program and connects it with `code`: on
44
45
  * macOS and Linux (`sh`), or in Windows' PowerShell. `base` is where the
45
- * release is (absolute), named only when it isn't the app's own.
46
+ * release is (absolute), named only when it isn't the app's own; `folder`,
47
+ * the folder its spaces go in (its org's name), plain, so the line needs no quoting.
46
48
  */
47
- export function installCommand(shell, { app, code, base }) {
49
+ export function installCommand(shell, { app, code, base, folder }) {
48
50
  const own = base === new URL(DOWNLOADS, app).href;
51
+ const named = folder ? plainName(folder, "") : "";
49
52
  return shell === "sh"
50
- ? `curl -fsSL ${base}install.sh | sh -s -- ${app} --code ${code}${own ? "" : ` --from ${base}`}`
51
- : `& ([scriptblock]::Create((irm ${base}install.ps1))) ${app} -Code ${code}${own ? "" : ` -From ${base}`}`;
53
+ ? `curl -fsSL ${base}install.sh | sh -s -- ${app} --code ${code}${own ? "" : ` --from ${base}`}${named ? ` --folder ${named}` : ""}`
54
+ : `& ([scriptblock]::Create((irm ${base}install.ps1))) ${app} -Code ${code}${own ? "" : ` -From ${base}`}${named ? ` -Folder ${named}` : ""}`;
52
55
  }
53
56
  /** A version's parts: its numbers, and its pre-release's (`1.0.0-rc.1`: rc, 1). */
54
57
  const parts = (version) => {
@@ -0,0 +1,2 @@
1
+ /** The computer program's version, as `computer.info` and `--version` say it, and its builds are named. */
2
+ export declare const VERSION = "1.0.0-rc.2";
@@ -0,0 +1,2 @@
1
+ /** The computer program's version, as `computer.info` and `--version` say it, and its builds are named. */
2
+ export const VERSION = "1.0.0-rc.2";
@@ -71,19 +71,19 @@ export const commands = {
71
71
  const [what, n, ...name] = rest.trim().split(/\s+/);
72
72
  const { space, all } = await listed(term);
73
73
  if (what === "connect") {
74
- const { code, expires, url, downloads } = await term.runtime.call("computer.code", space ? { in: space } : {});
74
+ const { code, expires, url, folder, downloads } = await term.runtime.call("computer.code", space ? { in: space } : {});
75
75
  const minutes = Math.round((Date.parse(expires) - Date.now()) / 60_000);
76
76
  const app = url ?? "<app url>";
77
77
  // From the app's release when it has one; else from a copy of the app's code.
78
78
  const base = downloads && url ? new URL(downloads.base, url).href : undefined;
79
79
  return await term.print(`On the computer this space's agents should use, run:\n\n` +
80
80
  (base
81
- ? ` ${installCommand("sh", { app, code, base })}\n\n` +
81
+ ? ` ${installCommand("sh", { app, code, base, folder })}\n\n` +
82
82
  `On Windows, in PowerShell:\n\n` +
83
- ` ${installCommand("powershell", { app, code, base })}\n\n` +
83
+ ` ${installCommand("powershell", { app, code, base, folder })}\n\n` +
84
84
  dim("It installs the computer program, connects it, and keeps it running (from " +
85
85
  "login on). Their folders go under ~/Copilotz there. ")
86
- : ` deno task computer connect ${app} --code ${code}\n\n` +
86
+ : ` deno task computer connect ${app} --code ${code}${folder ? ` --folder ${folder}` : ""}\n\n` +
87
87
  dim("Their folders go under ~/Copilotz there (--root puts them elsewhere). ")) +
88
88
  dim(`The code works once, for ${minutes} minutes.`));
89
89
  }
@@ -1,5 +1,9 @@
1
1
  import { type Plugin } from "../../../../runtime/mod.js";
2
2
  import { type Manifest } from "../program/release.js";
3
+ /** Where Copilotz's releases are: each one's computer program, at `v<version>/`. */
4
+ export declare const RELEASES = "https://github.com/copilotzhq/copilotz/releases/download/";
5
+ /** This Copilotz's release of the computer program (the default `computerDownloads`). */
6
+ export declare const releaseOf: (version?: string) => string;
3
7
  /**
4
8
  * What the resource says: where the release is (absolute, or on the app's
5
9
  * origin), and what's in it. The plugin's Actions read it by type alone, so
@@ -9,8 +13,8 @@ export type Downloads = {
9
13
  base: string;
10
14
  manifest: Manifest;
11
15
  };
12
- /** The app's downloads (see above), from a release folder of its own or at a URL. */
13
- export declare function computerDownloads(from: {
16
+ /** The app's downloads (see above): this Copilotz's release, a release folder of its own, or at a URL. */
17
+ export declare function computerDownloads(from?: {
14
18
  dir: string;
15
19
  } | {
16
20
  url: string;
@@ -5,14 +5,17 @@
5
5
  * connect flow shows the install command and the binaries from it; a
6
6
  * computer older than it updates itself to it (`computer.ready`).
7
7
  *
8
- * `computerDownloads` gives an app both, as a plugin: a release folder of its
8
+ * `computerDownloads` gives an app both, as a plugin: by default this
9
+ * Copilotz's own release on GitHub (`v<version>`: every release has the
10
+ * program, built again only when it changed); or a release folder of its
9
11
  * own, served from its origin at `/computer/download/` (a `routes` entry of
10
- * the web host: only the release's files, as they are), or one at a URL (a
11
- * GitHub release, a bucket), whose manifest it reads once, as it starts.
12
+ * the web host: only the release's files, as they are); or one at a URL (a
13
+ * bucket, another release). It reads the manifest once, as it starts.
12
14
  *
13
15
  * ```ts
14
- * plugins: [...plugins, await computerDownloads({ dir: "dist/computer/1.0.0-rc.1" })]
15
- * plugins: [...plugins, await computerDownloads({ url: "https://github.com/…/releases/download/…/" })]
16
+ * plugins: [...plugins, await computerDownloads()]
17
+ * plugins: [...plugins, await computerDownloads({ dir: "dist/computer/1.0.0-rc.2" })]
18
+ * plugins: [...plugins, await computerDownloads({ url: "https://downloads.example.com/computer/" })]
16
19
  * ```
17
20
  */
18
21
  import { createReadStream } from "node:fs";
@@ -21,6 +24,11 @@ import { join, resolve } from "node:path";
21
24
  import { Readable } from "node:stream";
22
25
  import { definePlugin } from "../../../../runtime/mod.js";
23
26
  import { DOWNLOADS } from "../program/release.js";
27
+ import { VERSION } from "../program/version.js";
28
+ /** Where Copilotz's releases are: each one's computer program, at `v<version>/`. */
29
+ export const RELEASES = "https://github.com/copilotzhq/copilotz/releases/download/";
30
+ /** This Copilotz's release of the computer program (the default `computerDownloads`). */
31
+ export const releaseOf = (version = VERSION) => `${RELEASES}v${version}/`;
24
32
  /** A manifest, checked for what everything reading it relies on. */
25
33
  function checked(value, from) {
26
34
  const manifest = value;
@@ -71,8 +79,8 @@ function serveRelease(dir, manifest) {
71
79
  return new Response(body, { headers });
72
80
  };
73
81
  }
74
- /** The app's downloads (see above), from a release folder of its own or at a URL. */
75
- export async function computerDownloads(from) {
82
+ /** The app's downloads (see above): this Copilotz's release, a release folder of its own, or at a URL. */
83
+ export async function computerDownloads(from = { url: releaseOf() }) {
76
84
  if ("url" in from) {
77
85
  const base = from.url.endsWith("/") ? from.url : `${from.url}/`;
78
86
  const response = await fetch(new URL("manifest.json", base));
@@ -1,2 +1,4 @@
1
- /** A space's folder name on a computer: its name, plain (one segment, lowercase), kept apart from the others'. */
1
+ /** A name as a folder's: plain (one segment, lowercase), or `fallback` when nothing's left. */
2
+ export declare function plainName(name: string, fallback?: string): string;
3
+ /** A space's folder name on a computer: its name, plain, kept apart from the others'. */
2
4
  export declare function folderFor(name: string, taken: string[]): string;
@@ -1,7 +1,11 @@
1
- /** A space's folder name on a computer: its name, plain (one segment, lowercase), kept apart from the others'. */
1
+ /** A name as a folder's: plain (one segment, lowercase), or `fallback` when nothing's left. */
2
+ export function plainName(name, fallback = "space") {
3
+ return name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase()
4
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || fallback;
5
+ }
6
+ /** A space's folder name on a computer: its name, plain, kept apart from the others'. */
2
7
  export function folderFor(name, taken) {
3
- const base = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase()
4
- .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "space";
8
+ const base = plainName(name);
5
9
  let folder = base;
6
10
  for (let n = 2; taken.includes(folder); n++)
7
11
  folder = `${base}-${n}`;
@@ -122,7 +122,7 @@ export function ConnectDialog({ open, onClose, call, computers, here, where, not
122
122
  ? (_jsxs(_Fragment, { children: [_jsx("span", { children: "This code has run out." }), _jsx(Button, { size: "sm", icon: "refresh", onClick: () => void fresh(), children: "New code" })] }))
123
123
  : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: "lock", size: 13 }), _jsxs("span", { children: ["The code works once, for", " ", _jsx("b", { className: "connect-left", children: clock(left) }), " more"] })] })) }))
124
124
  : null, release && code
125
- ? (_jsx(Direct, { release: release, system: system, mine: system === visitorSystem(), arch: arch, app: app, code: code.code, spent: expired }))
125
+ ? (_jsx(Direct, { release: release, system: system, mine: system === visitorSystem(), arch: arch, app: app, code: code.code, folder: code.folder, spent: expired }))
126
126
  : null] })] }), _jsxs("li", { children: [_jsx("span", { className: "connect-step-number", "aria-hidden": "true", children: "2" }), _jsx("div", { className: "connect-step", children: _jsx("p", { children: release
127
127
  ? (_jsxs(_Fragment, { children: ["It connects, then keeps running in the background, and starts when you log in. Each space gets a folder in", " ", _jsx("code", { children: system === "win32" ? "%USERPROFILE%\\Copilotz" : "~/Copilotz" }), " ", "on it."] }))
128
128
  : (_jsxs(_Fragment, { children: ["Leave it running. Each space gets a folder in ", _jsx("code", { children: "~/Copilotz" }), " ", "on that computer (", _jsx("code", { children: "--root" }), " puts them elsewhere), and next time", " ", _jsx("code", { children: "deno task computer" }), " starts it again."] })) }) })] }), code && !expired
@@ -135,16 +135,23 @@ export function ConnectDialog({ open, onClose, call, computers, here, where, not
135
135
  function Command({ code, app, system, spent }) {
136
136
  const release = code.downloads;
137
137
  if (!release) {
138
- const command = `deno task computer connect ${app} --code ${code.code}`;
138
+ const folder = code.folder ? ` --folder ${code.folder}` : "";
139
+ const command = `deno task computer connect ${app} --code ${code.code}${folder}`;
139
140
  return (_jsx(CodeBlock, { className: "connect-command", label: "The command to run", code: [
140
141
  { text: "$ ", tone: "muted" },
141
142
  { text: `deno task computer connect ${app} \\\n --code ` },
142
143
  { text: code.code, tone: spent ? "muted" : "accent" },
144
+ ...(folder ? [{ text: folder }] : []),
143
145
  ], copy: spent ? false : command }));
144
146
  }
145
147
  const base = within(release.base, app);
146
148
  const windows = system === "win32";
147
- const command = installCommand(windows ? "powershell" : "sh", { app, code: code.code, base });
149
+ const command = installCommand(windows ? "powershell" : "sh", {
150
+ app,
151
+ code: code.code,
152
+ base,
153
+ folder: code.folder,
154
+ });
148
155
  return (_jsx(CodeBlock, { className: "connect-command", label: "The command that installs it", code: parts(command, code.code, spent, windows ? "PS> " : "$ "), copy: spent ? false : command }));
149
156
  }
150
157
  /**
@@ -152,7 +159,7 @@ function Command({ code, app, system, spent }) {
152
159
  * when it's theirs and their architecture is known), each with its size and
153
160
  * SHA-256, and how to run one; behind a disclosure.
154
161
  */
155
- function Direct({ release, system, mine, arch, app, code, spent }) {
162
+ function Direct({ release, system, mine, arch, app, code, folder, spent }) {
156
163
  const base = within(release.base, app);
157
164
  const binaries = release.binaries.filter((b) => b.os === system)
158
165
  .sort((a, b) => Number(mine && b.arch === arch) - Number(mine && a.arch === arch));
@@ -165,9 +172,10 @@ function Direct({ release, system, mine, arch, app, code, spent }) {
165
172
  : undefined;
166
173
  const windows = system === "win32";
167
174
  const path = windows ? `$HOME\\Downloads\\${first.file}` : `~/Downloads/${first.file}`;
175
+ const named = folder ? ` --folder ${folder}` : "";
168
176
  const run = windows
169
- ? `& "${path}" install ${app} --code ${code}`
170
- : `chmod +x ${path} && ${path} install ${app} --code ${code}`;
177
+ ? `& "${path}" install ${app} --code ${code}${named}`
178
+ : `chmod +x ${path} && ${path} install ${app} --code ${code}${named}`;
171
179
  return (_jsxs("details", { className: "connect-direct", children: [_jsxs("summary", { children: [_jsx(Icon, { name: "chevron", size: 14, className: "connect-direct-chevron" }), "Download it instead", _jsxs("span", { className: "connect-direct-version", children: ["v", release.version] })] }), _jsxs("div", { className: "connect-direct-body", children: [_jsx("ul", { className: "connect-binaries", "aria-label": `For ${OS_NAMES[system]}`, children: binaries.map((b) => (_jsxs("li", { className: "connect-binary", children: [_jsxs("div", { className: "connect-binary-text", children: [_jsxs("span", { className: "connect-binary-name", children: [archName(b.os, b.arch), yours === b ? _jsx("span", { className: "connect-binary-yours", children: "this computer" }) : null] }), _jsxs("span", { className: "connect-binary-facts", children: [_jsx("code", { children: b.file }), " \u00B7 ", bytes(b.size)] }), _jsxs("span", { className: "connect-binary-sha", title: b.sha256, children: ["SHA-256 ", _jsxs("code", { children: [b.sha256.slice(0, 12), "\u2026", b.sha256.slice(-8)] }), _jsx(CopyButton, { text: b.sha256, label: "Copy SHA-256" })] })] }), _jsx(Button, { size: "sm", icon: "download", href: `${base}${b.file}`, download: b.file, children: "Download" })] }, b.file))) }), _jsx("p", { children: "Then, where you saved it, run it with your code:" }), _jsx(CodeBlock, { className: "connect-command", label: "How to run the download", code: parts(run, code, spent, windows ? "PS> " : "$ "), copy: spent ? false : run }), _jsxs("p", { className: "connect-unsigned", children: [_jsx(Icon, { name: "alert", size: 13 }), _jsx("span", { children: windows
172
180
  ? "The program isn't signed yet, so Windows may say it doesn't recognize it (More " +
173
181
  "info, then Run anyway). The command above doesn't need that."
@@ -21,13 +21,15 @@ import { type PaletteSource } from "../shared/palette.js";
21
21
  import type { UserMenuEntry } from "../shared/menu.js";
22
22
  import { type Offered } from "./panel.js";
23
23
  type Call = <R = unknown>(action: string, input?: unknown) => Promise<R>;
24
- export declare function Frame({ path, brand, viewer, sidebar, menu, call, panel, palette, children }: {
24
+ export declare function Frame({ path, brand, viewer, label, sidebar, menu, call, panel, palette, children }: {
25
25
  /** Where the page is: going elsewhere closes the drawer, and the panel's sheet. */
26
26
  path: string;
27
27
  /** The app's name, which its logo says (or shows). */
28
28
  brand: string;
29
29
  /** Who's looking: the panel is kept as they left it. */
30
30
  viewer: string;
31
+ /** What the viewer is shown as, when not their id (their name). */
32
+ label?: string;
31
33
  sidebar: ReactNode;
32
34
  /** What front ends put in the person's menu (the `userMenu` resource). */
33
35
  menu: UserMenuEntry[];
@@ -36,7 +36,7 @@ const phone = {
36
36
  },
37
37
  matches: () => matchMedia(PHONE).matches,
38
38
  };
39
- export function Frame({ path, brand, viewer, sidebar, menu, call, panel, palette, children }) {
39
+ export function Frame({ path, brand, viewer, label, sidebar, menu, call, panel, palette, children }) {
40
40
  const narrow = useSyncExternalStore(phone.subscribe, phone.matches);
41
41
  const [open, setOpen] = useState(false);
42
42
  useEffect(() => setOpen(false), [path, narrow]);
@@ -104,7 +104,7 @@ export function Frame({ path, brand, viewer, sidebar, menu, call, panel, palette
104
104
  : (_jsx("a", { className: "copilotz-brand", href: "/", children: _jsx(Logo, { variant: "full", name: brand, size: 32 }) })), narrow
105
105
  ? _jsx(IconButton, { icon: "close", label: "Close sidebar", onClick: () => setOpen(false) })
106
106
  : null] }), _jsx("div", { className: "copilotz-find", children: _jsx(NavItem, { quiet: true, icon: "search", onClick: find, end: narrow ? null : _jsx(Kbd, { children: shortcutOf(mac) }), title: `Search spaces and chats, or run a command (${shortcutOf(mac)})`, children: "Search" }) }), _jsx("nav", { className: "copilotz-sections", "aria-label": "Spaces and chats", children: sidebar }), viewer
107
- ? (_jsx(Me, { name: viewer, email: orgs?.email, menu: menu, ...(orgs ? { orgs: { here: !!here, standing, staff: orgs.staff } } : {}) }))
107
+ ? (_jsx(Me, { name: label ?? viewer, email: orgs?.email, menu: menu, ...(orgs ? { orgs: { here: !!here, standing, staff: orgs.staff } } : {}) }))
108
108
  : null] }));
109
109
  return (_jsxs("div", { className: "copilotz-shell", "data-staff": staff || undefined, children: [_jsx("style", { href: "web-shell", precedence: "copilotz", children: STYLE }), staff
110
110
  ? (_jsx("div", { className: "copilotz-staff-line", role: "note", children: _jsx("span", { className: "copilotz-sr", children: "You're in this org as platform staff: what you do is on its record." }) }))
@@ -19,7 +19,8 @@ export const shellView = (where, ctx) => {
19
19
  const open = kind ? ctx.show(kind, id ? { _id: id } : {}, asked) : ctx.show("home", {}, asked);
20
20
  const panel = ctx.resources.panel;
21
21
  const viewer = ctx.viewer?.name ?? "";
22
- return (_jsx(Frame, { path: where.path + (where.space ?? ""), brand: app, viewer: viewer, palette: {
22
+ const label = ctx.viewer?.label;
23
+ return (_jsx(Frame, { path: where.path + (where.space ?? ""), brand: app, viewer: viewer, ...(label ? { label } : {}), palette: {
23
24
  sources: sourcesOf(ctx.resources.palette),
24
25
  hints: {
25
26
  ...hints,
@@ -169,10 +169,15 @@ export type Merged = {
169
169
  export type Views = Record<string, Record<string, ViewDef>>;
170
170
  /** A View: an item, for a medium. Its context says what showing another View returns there. */
171
171
  export type ViewDef = (item: any, ctx: ViewContext<any>) => unknown;
172
- /** Who looks: a View renders relative to it (your own messages, your bubbles). */
172
+ /**
173
+ * Who looks: a View renders relative to it (your own messages, your bubbles).
174
+ * `name` is who they are (a person's id); `label` what they're shown as, when
175
+ * it isn't that (a person's own name, as they signed in).
176
+ */
173
177
  export type Viewer = {
174
178
  name: string;
175
179
  kind?: string;
180
+ label?: string;
176
181
  };
177
182
  export type RenderOptions = {
178
183
  viewer?: Viewer;