@quo-systems/dock 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,10 +8,17 @@ built on the dock, on Quo.
8
8
 
9
9
  ```bash
10
10
  npm install @quo-systems/dock
11
- npx quo init
12
- npx quo serve
11
+ npx quo estate my-estate --domain example.com
12
+ npx quo init --dir my-estate/droplet
13
+ npx quo serve --dir my-estate/droplet
13
14
  ```
14
15
 
16
+ `quo estate` writes an estate folder: one folder per harbor, the first a
17
+ droplet's quo directory with its routes under your domain, its unit and
18
+ its Caddyfile, and a package file that depends on the dock and nothing
19
+ else. Add your beings to `droplet/classes/`, and your placement to the
20
+ routes and agents files.
21
+
15
22
  `beings/quo-dock.md` is the truth of this package and the trunk of its four
16
23
  documents; read it first. It knows nothing the spec of Quo does not say,
17
24
  and adds no word to harbor, ward or being.
@@ -288,12 +288,13 @@ through the ask pointer or an avatar, the two doors everyone has.
288
288
  ```
289
289
  quo init [--dir D] mint a seed, boot a harbor and one ward over a disk store, write both
290
290
  quo serve [--dir D] [--dial URL] the daemon: harbor from disk, the unix socket, the routes' listeners, the agents, a socket held to URL
291
- quo pilot [--dir D] [--via S] the owner pilot over stdio: boot, invite, knock, remove, census; as the root, or via a standing at another ward
291
+ quo pilot [--dir D] [--via S] the owner pilot over stdio: the ward's describe as tools; as the root, or via a standing at another ward
292
292
  quo side [--dir D] --as NAME an avatar over stdio for a local client; NAME is the client identity
293
293
  quo run [--dir D] --as NAME --url URL --model NAME a human at a terminal talking to a model through an avatar; a line in, the final text out
294
294
  quo census [--dir D] print the census once and exit
295
- quo boot|invite|knock|remove the four owner asks from a shell, for scripts and for agents under cron; --via S sends them to another ward
295
+ quo boot|public|invite|knock|remove the five owner asks from a shell, for scripts and for agents under cron; --via S sends them to another ward
296
296
  quo reach <pk> <url> a hint for the harbor's directory: that world's ward lives at that quo. route
297
+ quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, its unit and Caddyfile, a package on this dock
297
298
  ```
298
299
 
299
300
  **One process owns the harbor.** `quo serve` is the only process that ever
@@ -502,13 +503,12 @@ above or sending us back to fix it:
502
503
  command: no droplet, no OAuth.
503
504
  5. Done. `quo pilot` in `packages/dock/mcp/pilot.ts`, proven in
504
505
  `packages/dock/test/cli.test.ts` with the SDK's stdio client as a child
505
- process: the owner's asks as tools and no more, census, boot, invite,
506
- knock and, since step 16, remove, each
507
- one an owner ask on the daemon's root socket, logged to stderr with
508
- what it made. It is the root and not an avatar: nothing is booted for
509
- it and no occupant is minted for it. With no daemon it says so and
510
- exits. Claude Code as the owner is `claude mcp add` with
511
- `quo pilot` as the command.
506
+ process: the model side of step 3 over the ward's owner asks, the
507
+ ward's describe as tools and no list of its own, each call an owner ask
508
+ on the daemon's root socket, and nothing logged. It is the root and not
509
+ an avatar: nothing is booted for it and no occupant is minted for it.
510
+ With no daemon it says so and exits. Claude Code as the owner is
511
+ `claude mcp add` with `quo pilot` as the command.
512
512
  6. Done. `quo serve --http PORT`: the HTTP door on loopback, `/health`
513
513
  naming the wards, routes mounting by path so a hostname on the proxy is
514
514
  a path on the daemon; proven in `packages/dock/test/cli.test.ts`. The systemd
@@ -521,7 +521,10 @@ above or sending us back to fix it:
521
521
  code grant, refresh with rotation, opaque tokens mapping a bearer to a
522
522
  client identity, revoke by identity, and expiry. The conventions pinned
523
523
  here: a pending request and a code live ten minutes, an access token
524
- one hour, a refresh token thirty days; a code is spent the first time
524
+ one hour, a refresh token thirty days; a registered client lives ten
525
+ minutes, and as long as its refresh token once the human allowed it,
526
+ since the register door is open to anyone and what nobody allowed must
527
+ not stay on disk; a code is spent the first time
525
528
  it is offered, right or wrong; the client identity is chosen at the
526
529
  allow page, not taken from the client's registration, because a client
527
530
  like claude.ai registers a fresh client id on every connect and the
@@ -651,14 +654,23 @@ above or sending us back to fix it:
651
654
  the library move waited for; the `ws` question answered in the harbor
652
655
  document. `estates/lab/lab.md` keeps the record, one slip included.
653
656
 
654
- 19. The split, in flight: the estate folder became the dock, the lab's own
655
- things moved to `estates/lab/`, its units and its document, and
656
- the dock's package took its name. Then the repository took the shape
657
- that says what ships by where it lives: `packages/quo/` and
658
- `packages/dock/` are the two published packages, `estates/` is private,
659
- and the root is nobody's package, the workspaces and the gate. The lab
660
- redeployed from the new paths and a copy of `estates/lab/` booting in
661
- an empty repository are what close it.
657
+ 19. The split: Done. The estate folder became the dock, the lab's own
658
+ things moved to `estates/lab/`, and the dock's package took its name.
659
+ Then the repository took the shape that says what ships by where it
660
+ lives: `packages/quo/` and `packages/dock/` are the two published
661
+ packages, `estates/` is private, and the root is nobody's package, the
662
+ workspaces and the gate. The lab is an estate folder of the shape this
663
+ document names, one folder per harbor, and `estates/lab/test/boot.test.ts`
664
+ is invariant 12 proven: a copy of the folder, the two packages installed
665
+ into it and nothing else, and the droplet boots. The lab redeployed
666
+ from it: the droplet from a clean export of HEAD with its unit copied
667
+ from the estate's folder, the edge from the estate's own config
668
+ resolving the dock's worker by name, the Mac's agent on the new path.
669
+ What it taught: a nested npm under `npm run` inherits the outer run's
670
+ local prefix by environment and installs into the repository instead
671
+ of the empty folder, so the stranger tests keep nothing of it; and
672
+ `npx` in a workspace runs at the package root, so an estate names its
673
+ edge config from its own folder.
662
674
  20. The dock published: Done. `@quo-systems/dock` 0.1.0, the same shape
663
675
  the library took, an emitted `dist/` with the `quo` command pointing
664
676
  into it, the source and the four documents beside it, and
@@ -671,9 +683,34 @@ above or sending us back to fix it:
671
683
  source, whichever is there, the one place the dock reads a file by
672
684
  path at run time.
673
685
 
674
- Steps 1 to 18 and 20 are in the tree and on the lab, and MCP is complete;
675
- step 19 is in flight, and it is the one gap named today. `next.md` is where
676
- the next sitting starts.
686
+ 21. A second estate: Done, and live. `estates/gherghina/`,
687
+ staging like the lab and nothing more, one harbor on the shared droplet
688
+ `nervur-staging-0`, routes under `gherghina.quo.systems`, no agents and
689
+ no classes of its own yet, the same unit and Caddyfile shape as the
690
+ lab's. It is the first proof that the dock has two users: its folder
691
+ depends on the dock alone, and `estates/gherghina/test/boot.test.ts`
692
+ runs the one proof every estate here runs, `estates/proof.ts`, shared
693
+ between them and shipped nowhere. The three staging droplets are shared
694
+ by every estate in this repository, and none of them is production or
695
+ holds real data. Its droplet stood up the same day, its document says
696
+ how; what it taught went into both estates' units: the agent's folders
697
+ are optional to the daemon, since a harbor may have no agent.
698
+
699
+ 22. The scaffold: Done. `quo estate <dir> --domain D` writes an estate
700
+ folder of the shape the two estates here agreed on: a package file
701
+ depending on this dock and nothing else, one document to fill in, and
702
+ `droplet/` as the first harbor, its routes under the domain, no
703
+ agents, no classes of its own, and that device's unit and Caddyfile
704
+ from templates the dock ships beside its command. The unit it writes
705
+ runs the installed dock from the estate's folder on the box, since an
706
+ adopter has no tree there; the two estates here run the tree. Proven
707
+ in `packages/dock/test/cli.test.ts`: the folder is written, refuses to
708
+ overwrite a package or take a name that is no domain, and its droplet
709
+ boots, `quo init` into it and a daemon answering the census.
710
+
711
+ Steps 1 to 22 are in the tree and on the machines, and MCP is complete;
712
+ the documents and the folders agree up to step 22. There is no gap named
713
+ today. `next.md` is where the next sitting starts.
677
714
 
678
715
  ## Glossary
679
716
 
@@ -688,7 +725,7 @@ the next sitting starts.
688
725
  - **front desk**: the being every fresh client meets; hands out invitations
689
726
  minted by user beings.
690
727
  - **gate**: `for(occupant, asker)`; the whole permission model.
691
- - **owner pilot**: `quo pilot`, the four owner asks over stdio, reaching
728
+ - **owner pilot**: `quo pilot`, the model side over a ward's owner asks, reaching
692
729
  the daemon's socket; with `--via`, a ward elsewhere through a standing.
693
730
  - **daemon**: `quo serve`, the one process on a device that holds a harbor.
694
731
  - **proof**: what a route produces after authenticating; the front desk
package/beings/setup.ts CHANGED
@@ -15,7 +15,9 @@ export async function setup(hosted: Hosted, user: string): Promise<void> {
15
15
  const out = (await hosted.ask('boot', args)) as { booted?: string; error?: string };
16
16
  if (out.error) throw new Error(`init: ${out.error}`);
17
17
  };
18
- await boot({ key: 'desk', class: 'Desk', public: true });
18
+ await boot({ key: 'desk', class: 'Desk' });
19
+ const pub = (await hosted.ask('public', { key: 'desk' })) as { error?: string };
20
+ if (pub.error) throw new Error(`init: ${pub.error}`);
19
21
  await boot({ key: user, class: 'User' });
20
22
  const inv = (await hosted.ask('invite', { being: user, id: DESK })) as Invitation;
21
23
  const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello' })) as { taken?: string };
@@ -0,0 +1,25 @@
1
+ # Three hostnames, one daemon, one loopback port. Caddy faces the world and
2
+ # terminates TLS; the daemon never does. Each hostname is a route, and a
3
+ # route is a path on the daemon's HTTP door. Replace DOMAIN.
4
+ #
5
+ # mcp. the model side: streamable HTTP MCP, with the credential exchange in front
6
+ # web. the bundle for plain tabs, and the exchange pages
7
+ # quo. the socket door for other harbors: requests in, sockets held, the rendezvous
8
+ #
9
+ # There is no cli. route. The root owner is the unix socket, reached on the
10
+ # device or over SSH, and never through here.
11
+
12
+ mcp.DOMAIN {
13
+ reverse_proxy 127.0.0.1:8787
14
+ rewrite * /mcp{uri}
15
+ }
16
+
17
+ web.DOMAIN {
18
+ reverse_proxy 127.0.0.1:8787
19
+ rewrite * /web{uri}
20
+ }
21
+
22
+ quo.DOMAIN {
23
+ reverse_proxy 127.0.0.1:8787
24
+ rewrite * /quo{uri}
25
+ }
@@ -0,0 +1,36 @@
1
+ # The daemon on a droplet, as one user, forever. Install with:
2
+ # sudo cp droplet/quo.service /etc/systemd/system/quo.service
3
+ # sudo systemctl enable --now quo
4
+ # The unit runs as the `quo` user, whose home holds the estate folder and
5
+ # the harbor folder, so the seed, the lease and the sockets are that user's
6
+ # alone. The HTTP door is loopback only; Caddy fronts it. See Caddyfile
7
+ # beside this file.
8
+ [Unit]
9
+ Description=quo: one harbor on this device
10
+ After=network.target
11
+
12
+ [Service]
13
+ User=quo
14
+ Group=quo
15
+ Environment=QUO_DIR=/home/quo/.quo
16
+ Environment=QUO_HTTP=8787
17
+ # QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
18
+ # CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
19
+ # reads its credential from the device, never from cells.
20
+ EnvironmentFile=-/etc/quo/env
21
+ WorkingDirectory=/home/quo/ESTATE
22
+ ExecStart=/usr/bin/node /home/quo/ESTATE/node_modules/@quo-systems/dock/dist/cli/quo.js serve
23
+ Restart=always
24
+ RestartSec=2
25
+ KillSignal=SIGTERM
26
+ TimeoutStopSec=10
27
+ NoNewPrivileges=true
28
+ ProtectSystem=strict
29
+ # Agents run in their folders and Claude Code keeps its own state in the
30
+ # user's home; both must be writable for a run to happen, and a harbor
31
+ # with no agent has neither.
32
+ ReadWritePaths=/home/quo/.quo -/home/quo/agents -/home/quo/.claude -/home/quo/.claude.json /tmp
33
+ PrivateTmp=false
34
+
35
+ [Install]
36
+ WantedBy=multi-user.target
package/cli/estate.ts ADDED
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // `quo estate DIR --domain D`: an estate folder, written once, of the shape
3
+ // every estate has. One folder per harbor, here the first, `droplet/`: the
4
+ // quo directory that device runs minus what it mints, its routes under the
5
+ // domain, no agents, no classes of its own, and that device's unit and
6
+ // Caddyfile. A package file that depends on this dock and nothing else, and
7
+ // one document to fill in. An estate needs nothing the dock does not give
8
+ // it, and this is the dock giving it.
9
+ import { existsSync } from 'node:fs';
10
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { basename, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ // This package's own version, read beside the emitted or the source tree.
15
+ async function version(): Promise<string> {
16
+ for (const rel of ['../package.json', '../../package.json']) {
17
+ const at = fileURLToPath(new URL(rel, import.meta.url));
18
+ if (!existsSync(at)) continue;
19
+ const pkg = JSON.parse(await readFile(at, 'utf8')) as { name?: string; version?: string };
20
+ if (pkg.name === '@quo-systems/dock' && pkg.version) return pkg.version;
21
+ }
22
+ return '0.1.0';
23
+ }
24
+ const template = (name: string) => readFile(fileURLToPath(new URL(`estate/${name}`, import.meta.url)), 'utf8');
25
+
26
+ export async function estate(dir: string, domain: string): Promise<{ dir: string; name: string; files: string[] }> {
27
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) throw new Error(`estate: ${domain} is not a domain`);
28
+ if (existsSync(join(dir, 'package.json'))) throw new Error(`estate: ${dir} already holds a package`);
29
+ const name = basename(dir);
30
+ const files: Record<string, string> = {
31
+ 'package.json': JSON.stringify({ name, private: true, description: `The ${name} estate: one owner's harbors, built on the dock and nothing else.`, type: 'module', dependencies: { '@quo-systems/dock': `^${await version()}` } }, null, 2) + '\n',
32
+ [`${name}.md`]: `# ${name}\n\nAn estate: all of one owner's harbors. One folder per harbor, each the quo\ndirectory that device runs minus what it mints, plus that device's unit.\n\`droplet/\` is the first: its routes under \`${domain}\`, its agents, its\nclasses, its systemd unit and its Caddyfile. This folder depends on the\ndock and nothing else.\n\n## The droplet\n\nWhere it is, how it is reached, and what was done to stand it up: yours\nto write.\n`,
33
+ 'droplet/routes.json': JSON.stringify({ mcp: `https://mcp.${domain}`, web: `https://web.${domain}`, quo: `https://quo.${domain}` }) + '\n',
34
+ 'droplet/agents.json': '{}\n',
35
+ 'droplet/classes/index.ts': '// The classes this harbor holds beside the dock\'s built-in ones, exported\n// by name.\nexport {};\n',
36
+ 'droplet/quo.service': (await template('quo.service')).replaceAll('ESTATE', name),
37
+ 'droplet/Caddyfile': (await template('Caddyfile')).replaceAll('DOMAIN', domain),
38
+ };
39
+ for (const [rel, text] of Object.entries(files)) {
40
+ await mkdir(join(dir, rel, '..'), { recursive: true });
41
+ await writeFile(join(dir, rel), text);
42
+ }
43
+ return { dir, name, files: Object.keys(files) };
44
+ }
package/cli/quo.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  // quo run [--dir D] [--ward W] --as NAME --url URL --model NAME [--turns N] a human at a terminal talking to a model through an avatar: a line in, the model's final text out
16
16
  // quo pilot [--dir D] [--ward W] [--via S] the owner pilot over stdio: census, boot, invite, knock, remove
17
17
  // quo reach [--dir D] <pk> <url> a hint for the harbor's directory: that ward pk lives at that quo. route
18
+ // quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, unit and Caddyfile, on this dock
18
19
  //
19
20
  // D defaults to $QUO_DIR, then ~/.quo. W defaults to main. init takes
20
21
  // --user NAME for the user being, defaulting to the device's user. run
@@ -31,8 +32,9 @@ import { DiskHarbor } from '../harbor/disk.ts';
31
32
  import { serve } from './daemon.ts';
32
33
  import { ask, side, reach, type Run } from './client.ts';
33
34
  import { pilotSide } from '../mcp/pilot.ts';
35
+ import { estate } from './estate.ts';
34
36
 
35
- const OWNER_ASKS = new Set(['boot', 'invite', 'knock', 'remove']);
37
+ const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove']);
36
38
 
37
39
  function parse(argv: string[]) {
38
40
  const flags: Record<string, string> = {};
@@ -80,7 +82,7 @@ async function main(argv: string[]): Promise<number> {
80
82
  }
81
83
  if (cmd === 'pilot') {
82
84
  await ask(dir, undefined, {}, ward); // no daemon is a clear word now, not at the first tool call
83
- const serving = await pilotSide(dir, ward, new StdioServerTransport(), (line) => console.error(`pilot ${line}`), via);
85
+ const serving = await pilotSide(dir, ward, new StdioServerTransport(), via);
84
86
  process.stdin.once('end', () => void serving.close().then(() => process.exit(0)));
85
87
  return -1;
86
88
  }
@@ -93,6 +95,14 @@ async function main(argv: string[]): Promise<number> {
93
95
  process.once('SIGTERM', stop);
94
96
  return -1; // stays up
95
97
  }
98
+ if (cmd === 'estate') {
99
+ if (!json || !flags.domain) {
100
+ console.error('quo estate needs <dir> --domain D');
101
+ return 2;
102
+ }
103
+ print(await estate(json, flags.domain));
104
+ return 0;
105
+ }
96
106
  if (cmd === 'reach') {
97
107
  if (!json || !more) {
98
108
  console.error('quo reach needs <pk> <url>');
@@ -111,7 +121,7 @@ async function main(argv: string[]): Promise<number> {
111
121
  const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
112
122
  return failed ? 1 : 0;
113
123
  }
114
- console.error('usage: quo init|serve|census|boot|invite|knock|remove|side|run|pilot|reach [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
124
+ console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
115
125
  return 2;
116
126
  }
117
127
 
@@ -7,7 +7,10 @@ export async function setup(hosted, user) {
7
7
  if (out.error)
8
8
  throw new Error(`init: ${out.error}`);
9
9
  };
10
- await boot({ key: 'desk', class: 'Desk', public: true });
10
+ await boot({ key: 'desk', class: 'Desk' });
11
+ const pub = (await hosted.ask('public', { key: 'desk' }));
12
+ if (pub.error)
13
+ throw new Error(`init: ${pub.error}`);
11
14
  await boot({ key: user, class: 'User' });
12
15
  const inv = (await hosted.ask('invite', { being: user, id: DESK }));
13
16
  const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello' }));
@@ -0,0 +1,25 @@
1
+ # Three hostnames, one daemon, one loopback port. Caddy faces the world and
2
+ # terminates TLS; the daemon never does. Each hostname is a route, and a
3
+ # route is a path on the daemon's HTTP door. Replace DOMAIN.
4
+ #
5
+ # mcp. the model side: streamable HTTP MCP, with the credential exchange in front
6
+ # web. the bundle for plain tabs, and the exchange pages
7
+ # quo. the socket door for other harbors: requests in, sockets held, the rendezvous
8
+ #
9
+ # There is no cli. route. The root owner is the unix socket, reached on the
10
+ # device or over SSH, and never through here.
11
+
12
+ mcp.DOMAIN {
13
+ reverse_proxy 127.0.0.1:8787
14
+ rewrite * /mcp{uri}
15
+ }
16
+
17
+ web.DOMAIN {
18
+ reverse_proxy 127.0.0.1:8787
19
+ rewrite * /web{uri}
20
+ }
21
+
22
+ quo.DOMAIN {
23
+ reverse_proxy 127.0.0.1:8787
24
+ rewrite * /quo{uri}
25
+ }
@@ -0,0 +1,36 @@
1
+ # The daemon on a droplet, as one user, forever. Install with:
2
+ # sudo cp droplet/quo.service /etc/systemd/system/quo.service
3
+ # sudo systemctl enable --now quo
4
+ # The unit runs as the `quo` user, whose home holds the estate folder and
5
+ # the harbor folder, so the seed, the lease and the sockets are that user's
6
+ # alone. The HTTP door is loopback only; Caddy fronts it. See Caddyfile
7
+ # beside this file.
8
+ [Unit]
9
+ Description=quo: one harbor on this device
10
+ After=network.target
11
+
12
+ [Service]
13
+ User=quo
14
+ Group=quo
15
+ Environment=QUO_DIR=/home/quo/.quo
16
+ Environment=QUO_HTTP=8787
17
+ # QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
18
+ # CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
19
+ # reads its credential from the device, never from cells.
20
+ EnvironmentFile=-/etc/quo/env
21
+ WorkingDirectory=/home/quo/ESTATE
22
+ ExecStart=/usr/bin/node /home/quo/ESTATE/node_modules/@quo-systems/dock/dist/cli/quo.js serve
23
+ Restart=always
24
+ RestartSec=2
25
+ KillSignal=SIGTERM
26
+ TimeoutStopSec=10
27
+ NoNewPrivileges=true
28
+ ProtectSystem=strict
29
+ # Agents run in their folders and Claude Code keeps its own state in the
30
+ # user's home; both must be writable for a run to happen, and a harbor
31
+ # with no agent has neither.
32
+ ReadWritePaths=/home/quo/.quo -/home/quo/agents -/home/quo/.claude -/home/quo/.claude.json /tmp
33
+ PrivateTmp=false
34
+
35
+ [Install]
36
+ WantedBy=multi-user.target
@@ -0,0 +1,5 @@
1
+ export declare function estate(dir: string, domain: string): Promise<{
2
+ dir: string;
3
+ name: string;
4
+ files: string[];
5
+ }>;
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // `quo estate DIR --domain D`: an estate folder, written once, of the shape
3
+ // every estate has. One folder per harbor, here the first, `droplet/`: the
4
+ // quo directory that device runs minus what it mints, its routes under the
5
+ // domain, no agents, no classes of its own, and that device's unit and
6
+ // Caddyfile. A package file that depends on this dock and nothing else, and
7
+ // one document to fill in. An estate needs nothing the dock does not give
8
+ // it, and this is the dock giving it.
9
+ import { existsSync } from 'node:fs';
10
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { basename, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ // This package's own version, read beside the emitted or the source tree.
14
+ async function version() {
15
+ for (const rel of ['../package.json', '../../package.json']) {
16
+ const at = fileURLToPath(new URL(rel, import.meta.url));
17
+ if (!existsSync(at))
18
+ continue;
19
+ const pkg = JSON.parse(await readFile(at, 'utf8'));
20
+ if (pkg.name === '@quo-systems/dock' && pkg.version)
21
+ return pkg.version;
22
+ }
23
+ return '0.1.0';
24
+ }
25
+ const template = (name) => readFile(fileURLToPath(new URL(`estate/${name}`, import.meta.url)), 'utf8');
26
+ export async function estate(dir, domain) {
27
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain))
28
+ throw new Error(`estate: ${domain} is not a domain`);
29
+ if (existsSync(join(dir, 'package.json')))
30
+ throw new Error(`estate: ${dir} already holds a package`);
31
+ const name = basename(dir);
32
+ const files = {
33
+ 'package.json': JSON.stringify({ name, private: true, description: `The ${name} estate: one owner's harbors, built on the dock and nothing else.`, type: 'module', dependencies: { '@quo-systems/dock': `^${await version()}` } }, null, 2) + '\n',
34
+ [`${name}.md`]: `# ${name}\n\nAn estate: all of one owner's harbors. One folder per harbor, each the quo\ndirectory that device runs minus what it mints, plus that device's unit.\n\`droplet/\` is the first: its routes under \`${domain}\`, its agents, its\nclasses, its systemd unit and its Caddyfile. This folder depends on the\ndock and nothing else.\n\n## The droplet\n\nWhere it is, how it is reached, and what was done to stand it up: yours\nto write.\n`,
35
+ 'droplet/routes.json': JSON.stringify({ mcp: `https://mcp.${domain}`, web: `https://web.${domain}`, quo: `https://quo.${domain}` }) + '\n',
36
+ 'droplet/agents.json': '{}\n',
37
+ 'droplet/classes/index.ts': '// The classes this harbor holds beside the dock\'s built-in ones, exported\n// by name.\nexport {};\n',
38
+ 'droplet/quo.service': (await template('quo.service')).replaceAll('ESTATE', name),
39
+ 'droplet/Caddyfile': (await template('Caddyfile')).replaceAll('DOMAIN', domain),
40
+ };
41
+ for (const [rel, text] of Object.entries(files)) {
42
+ await mkdir(join(dir, rel, '..'), { recursive: true });
43
+ await writeFile(join(dir, rel), text);
44
+ }
45
+ return { dir, name, files: Object.keys(files) };
46
+ }
package/dist/cli/quo.js CHANGED
@@ -15,6 +15,7 @@
15
15
  // quo run [--dir D] [--ward W] --as NAME --url URL --model NAME [--turns N] a human at a terminal talking to a model through an avatar: a line in, the model's final text out
16
16
  // quo pilot [--dir D] [--ward W] [--via S] the owner pilot over stdio: census, boot, invite, knock, remove
17
17
  // quo reach [--dir D] <pk> <url> a hint for the harbor's directory: that ward pk lives at that quo. route
18
+ // quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, unit and Caddyfile, on this dock
18
19
  //
19
20
  // D defaults to $QUO_DIR, then ~/.quo. W defaults to main. init takes
20
21
  // --user NAME for the user being, defaulting to the device's user. run
@@ -31,7 +32,8 @@ import { DiskHarbor } from '../harbor/disk.js';
31
32
  import { serve } from './daemon.js';
32
33
  import { ask, side, reach } from './client.js';
33
34
  import { pilotSide } from '../mcp/pilot.js';
34
- const OWNER_ASKS = new Set(['boot', 'invite', 'knock', 'remove']);
35
+ import { estate } from './estate.js';
36
+ const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove']);
35
37
  function parse(argv) {
36
38
  const flags = {};
37
39
  const rest = [];
@@ -78,7 +80,7 @@ async function main(argv) {
78
80
  }
79
81
  if (cmd === 'pilot') {
80
82
  await ask(dir, undefined, {}, ward); // no daemon is a clear word now, not at the first tool call
81
- const serving = await pilotSide(dir, ward, new StdioServerTransport(), (line) => console.error(`pilot ${line}`), via);
83
+ const serving = await pilotSide(dir, ward, new StdioServerTransport(), via);
82
84
  process.stdin.once('end', () => void serving.close().then(() => process.exit(0)));
83
85
  return -1;
84
86
  }
@@ -91,6 +93,14 @@ async function main(argv) {
91
93
  process.once('SIGTERM', stop);
92
94
  return -1; // stays up
93
95
  }
96
+ if (cmd === 'estate') {
97
+ if (!json || !flags.domain) {
98
+ console.error('quo estate needs <dir> --domain D');
99
+ return 2;
100
+ }
101
+ print(await estate(json, flags.domain));
102
+ return 0;
103
+ }
94
104
  if (cmd === 'reach') {
95
105
  if (!json || !more) {
96
106
  console.error('quo reach needs <pk> <url>');
@@ -109,7 +119,7 @@ async function main(argv) {
109
119
  const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
110
120
  return failed ? 1 : 0;
111
121
  }
112
- console.error('usage: quo init|serve|census|boot|invite|knock|remove|side|run|pilot|reach [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
122
+ console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
113
123
  return 2;
114
124
  }
115
125
  main(process.argv.slice(2)).then((code) => {
@@ -63,7 +63,9 @@ function probe(base, state, env) {
63
63
  learn(i, w);
64
64
  }
65
65
  wardOf.set(key, name);
66
- assert.deepEqual(await w.ask('boot', { key, class: Class.name, public: isPublic }), { booted: key });
66
+ assert.deepEqual(await w.ask('boot', { key, class: Class.name }), { booted: key });
67
+ if (isPublic)
68
+ assert.deepEqual(await w.ask('public', { key }), { public: key });
67
69
  if (Object.keys(cells).length)
68
70
  Object.assign(at(name).p.beings[key], cells);
69
71
  return {
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The edge platform, as much of it as the harbor touches and no more. The
3
+ // vendor's own type package declares every global a worker has, and it
4
+ // collides with the DOM library this tree compiles against; these few
5
+ // lines are what the edge harbor and worker actually call.
6
+ export type Storage = {
7
+ get<T = unknown>(key: string): Promise<T | undefined>;
8
+ get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
9
+ put(key: string, value: unknown): Promise<void>;
10
+ delete(key: string): Promise<boolean>;
11
+ list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
12
+ };
13
+ export type State = { id: { toString(): string; name?: string }; storage: Storage };
14
+ export type Stub = { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
15
+ export type Namespace = { idFromName(name: string): unknown; get(id: unknown): Stub };
16
+ export type Env = { HARBOR: Namespace; QUO_SEED_KEY?: string; QUO_ROOT?: string; QUO_EXERCISE?: string };
17
+ // The server end of a WebSocketPair: the standard surface plus accept.
18
+ export type ServerSocket = { accept(): void; readyState: number; binaryType: string; send(data: string | Uint8Array): void; close(): void; addEventListener(type: string, fn: (e: never) => void): void };
19
+ declare global {
20
+ // eslint-disable-next-line no-var
21
+ var WebSocketPair: new () => { 0: ServerSocket; 1: ServerSocket };
22
+ interface ResponseInit {
23
+ webSocket?: unknown;
24
+ }
25
+ interface Response {
26
+ readonly webSocket?: unknown;
27
+ }
28
+ }
@@ -6,6 +6,7 @@ export type Client = {
6
6
  client_id: string;
7
7
  client_name: string;
8
8
  redirect_uris: string[];
9
+ exp: number;
9
10
  };
10
11
  export type Pending = {
11
12
  client_id: string;
@@ -62,6 +63,7 @@ export declare class OAuth {
62
63
  register(body: unknown): Promise<Client | {
63
64
  error: string;
64
65
  }>;
66
+ client(id: string): Client | undefined;
65
67
  authorize(q: URLSearchParams): Promise<{
66
68
  redirect: string;
67
69
  } | {
package/dist/mcp/oauth.js CHANGED
@@ -60,15 +60,22 @@ export class OAuth {
60
60
  const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
61
61
  if (uris.length === 0)
62
62
  return { error: 'invalid_redirect_uri' };
63
- const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
63
+ const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris, exp: this.now() + CODE_TTL };
64
64
  this.store.clients[client.client_id] = client;
65
+ this.sweep();
65
66
  await this.o.persist(this.store);
66
- return client;
67
+ const { exp: _, ...shown } = client;
68
+ return shown;
69
+ }
70
+ // A client still alive, by id.
71
+ client(id) {
72
+ const c = own(this.store.clients, id);
73
+ return c && c.exp >= this.now() ? c : undefined;
67
74
  }
68
75
  // Start a request. What comes back is where to send the browser: the web
69
76
  // route with the request id, or the client's redirect with an error.
70
77
  async authorize(q) {
71
- const client = own(this.store.clients, q.get('client_id') ?? '');
78
+ const client = this.client(q.get('client_id') ?? '');
72
79
  const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
73
80
  if (!client || redirect === null || !client.redirect_uris.includes(redirect))
74
81
  return { error: 'invalid_client' };
@@ -89,7 +96,7 @@ export class OAuth {
89
96
  const p = own(this.store.pending, id);
90
97
  if (!p || p.exp < this.now())
91
98
  return null;
92
- const client = own(this.store.clients, p.client_id);
99
+ const client = this.client(p.client_id);
93
100
  return client ? { ...p, client } : null;
94
101
  }
95
102
  // The web route's last step: the human allowed this client as this identity.
@@ -140,6 +147,9 @@ export class OAuth {
140
147
  const access_token = token(), refresh_token = token();
141
148
  this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
142
149
  this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
150
+ const c = own(this.store.clients, client_id);
151
+ if (c)
152
+ c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
143
153
  this.sweep();
144
154
  await this.o.persist(this.store);
145
155
  return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
@@ -162,10 +172,10 @@ export class OAuth {
162
172
  }
163
173
  sweep() {
164
174
  const now = this.now();
165
- for (const k of ['pending', 'codes', 'access', 'refresh'])
175
+ for (const k of ['clients', 'pending', 'codes', 'access', 'refresh'])
166
176
  for (const [t, g] of Object.entries(this.store[k]))
167
- if (g.exp < now)
168
- delete this.store[k][t];
177
+ if (!(g.exp >= now))
178
+ delete this.store[k][t]; // a record with no exp is from before there was one, and goes too
169
179
  }
170
180
  // The HTTP face. `rest` is the path under the route.
171
181
  async handle(req, res, rest) {
@@ -1,6 +1,5 @@
1
1
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
- import { type Tool } from '@modelcontextprotocol/sdk/types.js';
3
2
  import type { Serving } from '../beings/side.ts';
4
- export declare const PILOT_TOOLS: Tool[];
5
- export type Log = (line: string) => void;
6
- export declare function pilotSide(dir: string, ward: string, transport: Transport, log?: Log, via?: string): Promise<Serving>;
3
+ import { type Subject } from './server.ts';
4
+ export declare function owner(dir: string, ward: string, via?: string): Subject;
5
+ export declare function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving>;
package/dist/mcp/pilot.js CHANGED
@@ -1,68 +1,22 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- // The owner pilot: `quo pilot`. An MCP server over stdio that reaches a
3
- // ward's owner asks through the daemon's root socket, and exposes four tools
4
- // and no more, plus the one read. Whoever runs this process is the owner, by
5
- // the device's own rules: the root of the ward here, or, with `via`, an
6
- // owner at another ward's door, on a standing the user being here holds
7
- // there. Every call is logged with what it made. The owner creates and
8
- // places; the work goes through `quo side`, under a gate.
9
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
- import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
11
1
  import { ask } from '../cli/client.js';
12
- export const PILOT_TOOLS = [
13
- {
14
- name: 'census',
15
- description: 'the empty ask: the ward pk and every being, with class, public and digest',
16
- inputSchema: { type: 'object', properties: {} },
17
- },
18
- {
19
- name: 'boot',
20
- description: 'boot a being by class name under a key; public marks the one public being',
21
- inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
22
- },
23
- {
24
- name: 'invite',
25
- description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
26
- inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
27
- },
28
- {
29
- name: 'knock',
30
- description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
31
- inputSchema: {
32
- type: 'object',
33
- properties: {
34
- being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
35
- id: { type: 'string' },
36
- invitation: { type: 'object' },
37
- method: { type: 'string' },
38
- args: { type: 'object' },
39
- wanted: { type: 'object', properties: { time: { type: 'number' } } },
40
- },
41
- required: ['being', 'id', 'invitation'],
42
- },
43
- },
44
- {
45
- name: 'remove',
46
- description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
47
- inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
48
- },
49
- ];
2
+ import { mcpSide } from './server.js';
50
3
  const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
51
- export async function pilotSide(dir, ward, transport, log = () => { }, via) {
52
- const server = new Server({ name: 'quo-pilot', version: '0.0.0' }, { capabilities: { tools: {} } });
53
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: PILOT_TOOLS }));
54
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
55
- const name = req.params.name;
56
- const args = req.params.arguments ?? {};
57
- if (!PILOT_TOOLS.some((t) => t.name === name))
58
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown ask' }) }], structuredContent: { error: 'unknown ask' }, isError: true };
59
- const out = await ask(dir, name === 'census' ? undefined : name, args, ward, via);
60
- const value = 'error' in out ? { error: out.error } : out.result;
61
- const failed = 'error' in out || (typeof value === 'object' && value !== null && 'error' in value);
62
- log(`${via === undefined ? ward : `${ward} via ${via}`} ${name} ${JSON.stringify(args)} -> ${JSON.stringify(value)}`);
63
- const structured = isRecord(value) ? { structuredContent: value } : {};
64
- return { content: [{ type: 'text', text: JSON.stringify(value) }], ...structured, ...(failed ? { isError: true } : {}) };
65
- });
66
- await server.connect(transport);
67
- return { close: () => server.close() };
4
+ // The owner hears objects: the socket's own failure, a silence and a word
5
+ // each come back as an error object named for what it was.
6
+ export function owner(dir, ward, via) {
7
+ const one = async (method, args) => {
8
+ const out = await ask(dir, method, args, ward, via);
9
+ if ('error' in out)
10
+ return { error: out.error };
11
+ const r = out.result;
12
+ if (isRecord(r) && r.silence === true)
13
+ return { error: 'silence' };
14
+ if (isRecord(r) && typeof r.word === 'string')
15
+ return { error: r.word };
16
+ return r;
17
+ };
18
+ return { tools: () => one(undefined, {}), call: (name, args) => one(name, args), ears: new Set() };
19
+ }
20
+ export function pilotSide(dir, ward, transport, via) {
21
+ return mcpSide(owner(dir, ward, via), transport);
68
22
  }
@@ -1,9 +1,16 @@
1
1
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
2
  import { type Tool } from '@modelcontextprotocol/sdk/types.js';
3
- import type { Blueprint } from '@quo-systems/quo';
4
- import type { Avatar } from '../beings/avatar.ts';
3
+ import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
5
4
  import { type Serving } from '../beings/side.ts';
6
5
  export declare const NAME = "quo";
7
6
  export declare const VERSION = "0.0.0";
7
+ export type Subject = {
8
+ tools(): Promise<Blueprint | {
9
+ error: string;
10
+ }>;
11
+ call(name: string, args: JsonObject): Promise<Answer>;
12
+ ears: Set<(object: JsonObject) => void>;
13
+ };
14
+ export declare const DESCRIBE: Tool;
8
15
  export declare function tools(bp: Blueprint): Tool[];
9
- export declare function mcpSide(avatar: Avatar, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
16
+ export declare function mcpSide(avatar: Subject, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
@@ -1,19 +1,24 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The model side as an MCP server: one avatar, one transport. The mapping is
3
- // total and needs nothing beyond this envelope. tools/list is her describe,
4
- // tools/call is a named ask on her one standing, a push is a logging
5
- // notification, and the three words for "no object" each cross as the table
6
- // in quo-mcp.md says. The transport is whatever the caller connected: the
2
+ // The model side as an MCP server: one subject, one transport. The subject
3
+ // is whoever answers the empty ask and a named one: an avatar on her one
4
+ // standing, or a ward's owner asks through the pilot. The mapping is total
5
+ // and needs nothing beyond this envelope. tools/list is her describe, and
6
+ // the empty ask itself is the first tool, so the notes of the describe are
7
+ // readable; tools/call is a named ask, a push is a logging notification,
8
+ // and the three words for "no object" each cross as the table in
9
+ // quo-mcp.md says. The transport is whatever the caller connected: the
7
10
  // SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
8
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
12
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
10
13
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
11
14
  export const NAME = 'quo';
12
15
  export const VERSION = '0.0.0';
13
- // Her describe, spoken as tools. Name, description and input are verbatim;
14
- // an output schema crosses when she declared one.
16
+ // The empty ask as a tool: her describe whole, asks and notes.
17
+ export const DESCRIBE = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
18
+ // Her describe, spoken as tools, the empty ask first. Name, description and
19
+ // input are verbatim; an output schema crosses when she declared one.
15
20
  export function tools(bp) {
16
- return bp.asks.map((a) => {
21
+ const asks = bp.asks.map((a) => {
17
22
  const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
18
23
  if (a.description !== undefined)
19
24
  t.description = a.description;
@@ -21,6 +26,7 @@ export function tools(bp) {
21
26
  t.outputSchema = { ...a.output, type: 'object' };
22
27
  return t;
23
28
  });
29
+ return [DESCRIBE, ...asks];
24
30
  }
25
31
  // `after` runs when a call is done: a harbor that must write what the ward
26
32
  // changed hooks it, since a same-ward ask never crosses the harbor.
@@ -33,7 +39,7 @@ export async function mcpSide(avatar, transport, after = async () => { }) {
33
39
  return { tools: tools(bp) };
34
40
  });
35
41
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
36
- const w = word(await avatar.call(req.params.name, (req.params.arguments ?? {})));
42
+ const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {})));
37
43
  await after();
38
44
  if (w.word === 'object') {
39
45
  const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
@@ -70,7 +70,8 @@ function probe(base: string, state: State, env: Env): () => Promise<World> {
70
70
  learn(i, w);
71
71
  }
72
72
  wardOf.set(key, name);
73
- assert.deepEqual(await w.ask('boot', { key, class: Class.name, public: isPublic }), { booted: key });
73
+ assert.deepEqual(await w.ask('boot', { key, class: Class.name }), { booted: key });
74
+ if (isPublic) assert.deepEqual(await w.ask('public', { key }), { public: key });
74
75
  if (Object.keys(cells).length) Object.assign(at(name).p.beings[key]!, cells);
75
76
  return {
76
77
  pk: w.pk,
package/mcp/oauth.ts CHANGED
@@ -28,7 +28,10 @@ export const CODE_TTL = 10 * 60 * 1000; // a code, and a pending request, live t
28
28
  export const ACCESS_TTL = 60 * 60 * 1000; // an access token, one hour
29
29
  export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty days
30
30
 
31
- export type Client = { client_id: string; client_name: string; redirect_uris: string[] };
31
+ // A client lives ten minutes from registration, the life of a request, and
32
+ // as long as its refresh token once the human allowed it: the register door
33
+ // is open to anyone, and what nobody allowed must not stay on disk.
34
+ export type Client = { client_id: string; client_name: string; redirect_uris: string[]; exp: number };
32
35
  export type Pending = { client_id: string; redirect_uri: string; challenge: string; state: string | null; resource: string | null; exp: number };
33
36
  export type Grant = { identity: string; client_id: string; exp: number };
34
37
  export type Store = {
@@ -87,16 +90,24 @@ export class OAuth {
87
90
  const b = body as { client_name?: unknown; redirect_uris?: unknown };
88
91
  const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u): u is string => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
89
92
  if (uris.length === 0) return { error: 'invalid_redirect_uri' };
90
- const client: Client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
93
+ const client: Client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris, exp: this.now() + CODE_TTL };
91
94
  this.store.clients[client.client_id] = client;
95
+ this.sweep();
92
96
  await this.o.persist(this.store);
93
- return client;
97
+ const { exp: _, ...shown } = client;
98
+ return shown as Client;
99
+ }
100
+
101
+ // A client still alive, by id.
102
+ client(id: string): Client | undefined {
103
+ const c = own(this.store.clients, id);
104
+ return c && c.exp >= this.now() ? c : undefined;
94
105
  }
95
106
 
96
107
  // Start a request. What comes back is where to send the browser: the web
97
108
  // route with the request id, or the client's redirect with an error.
98
109
  async authorize(q: URLSearchParams): Promise<{ redirect: string } | { error: string }> {
99
- const client = own(this.store.clients, q.get('client_id') ?? '');
110
+ const client = this.client(q.get('client_id') ?? '');
100
111
  const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
101
112
  if (!client || redirect === null || !client.redirect_uris.includes(redirect)) return { error: 'invalid_client' };
102
113
  const back = (error: string) => ({ redirect: withQuery(redirect, { error, state: q.get('state') }) });
@@ -114,7 +125,7 @@ export class OAuth {
114
125
  pending(id: string): (Pending & { client: Client }) | null {
115
126
  const p = own(this.store.pending, id);
116
127
  if (!p || p.exp < this.now()) return null;
117
- const client = own(this.store.clients, p.client_id);
128
+ const client = this.client(p.client_id);
118
129
  return client ? { ...p, client } : null;
119
130
  }
120
131
 
@@ -166,6 +177,8 @@ export class OAuth {
166
177
  refresh_token = token();
167
178
  this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
168
179
  this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
180
+ const c = own(this.store.clients, client_id);
181
+ if (c) c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
169
182
  this.sweep();
170
183
  await this.o.persist(this.store);
171
184
  return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
@@ -188,7 +201,7 @@ export class OAuth {
188
201
 
189
202
  sweep() {
190
203
  const now = this.now();
191
- for (const k of ['pending', 'codes', 'access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.exp < now) delete this.store[k][t];
204
+ for (const k of ['clients', 'pending', 'codes', 'access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (!(g.exp >= now)) delete this.store[k][t]; // a record with no exp is from before there was one, and goes too
192
205
  }
193
206
 
194
207
  // The HTTP face. `rest` is the path under the route.
package/mcp/pilot.ts CHANGED
@@ -1,73 +1,34 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The owner pilot: `quo pilot`. An MCP server over stdio that reaches a
3
- // ward's owner asks through the daemon's root socket, and exposes four tools
4
- // and no more, plus the one read. Whoever runs this process is the owner, by
5
- // the device's own rules: the root of the ward here, or, with `via`, an
6
- // owner at another ward's door, on a standing the user being here holds
7
- // there. Every call is logged with what it made. The owner creates and
8
- // places; the work goes through `quo side`, under a gate.
9
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ // The owner pilot: `quo pilot`. The model side pointed at a ward instead of
3
+ // an avatar. The ward is a being to her owner, so her describe is the tool
4
+ // list and a tool call is an owner ask, exactly as for anyone; nothing here
5
+ // names an ask. Whoever runs this process is the owner, by the device's own
6
+ // rules: the root of the ward here, through the daemon's socket, or, with
7
+ // `via`, an owner at another ward's door, on a standing the user being here
8
+ // holds there. The owner creates and places; the work goes through
9
+ // `quo side`, under a gate.
10
10
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
11
- import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
11
+ import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
12
12
  import { ask } from '../cli/client.ts';
13
13
  import type { Serving } from '../beings/side.ts';
14
+ import { mcpSide, type Subject } from './server.ts';
14
15
 
15
- export const PILOT_TOOLS: Tool[] = [
16
- {
17
- name: 'census',
18
- description: 'the empty ask: the ward pk and every being, with class, public and digest',
19
- inputSchema: { type: 'object', properties: {} },
20
- },
21
- {
22
- name: 'boot',
23
- description: 'boot a being by class name under a key; public marks the one public being',
24
- inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
25
- },
26
- {
27
- name: 'invite',
28
- description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
29
- inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
30
- },
31
- {
32
- name: 'knock',
33
- description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
34
- inputSchema: {
35
- type: 'object',
36
- properties: {
37
- being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
38
- id: { type: 'string' },
39
- invitation: { type: 'object' },
40
- method: { type: 'string' },
41
- args: { type: 'object' },
42
- wanted: { type: 'object', properties: { time: { type: 'number' } } },
43
- },
44
- required: ['being', 'id', 'invitation'],
45
- },
46
- },
47
- {
48
- name: 'remove',
49
- description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
50
- inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
51
- },
52
- ];
53
-
54
- export type Log = (line: string) => void;
55
16
  const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
56
17
 
57
- export async function pilotSide(dir: string, ward: string, transport: Transport, log: Log = () => {}, via?: string): Promise<Serving> {
58
- const server = new Server({ name: 'quo-pilot', version: '0.0.0' }, { capabilities: { tools: {} } });
59
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: PILOT_TOOLS }));
60
- server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
61
- const name = req.params.name;
62
- const args: Record<string, unknown> = req.params.arguments ?? {};
63
- if (!PILOT_TOOLS.some((t) => t.name === name)) return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown ask' }) }], structuredContent: { error: 'unknown ask' }, isError: true };
64
- const out = await ask(dir, name === 'census' ? undefined : name, args, ward, via);
65
- const value = 'error' in out ? { error: out.error } : out.result;
66
- const failed = 'error' in out || (typeof value === 'object' && value !== null && 'error' in value);
67
- log(`${via === undefined ? ward : `${ward} via ${via}`} ${name} ${JSON.stringify(args)} -> ${JSON.stringify(value)}`);
68
- const structured = isRecord(value) ? { structuredContent: value } : {};
69
- return { content: [{ type: 'text', text: JSON.stringify(value) }], ...structured, ...(failed ? { isError: true } : {}) };
70
- });
71
- await server.connect(transport);
72
- return { close: () => server.close() };
18
+ // The owner hears objects: the socket's own failure, a silence and a word
19
+ // each come back as an error object named for what it was.
20
+ export function owner(dir: string, ward: string, via?: string): Subject {
21
+ const one = async (method: string | undefined, args: JsonObject): Promise<Answer> => {
22
+ const out = await ask(dir, method, args, ward, via);
23
+ if ('error' in out) return { error: out.error };
24
+ const r = out.result;
25
+ if (isRecord(r) && r.silence === true) return { error: 'silence' };
26
+ if (isRecord(r) && typeof r.word === 'string') return { error: r.word };
27
+ return r as Answer;
28
+ };
29
+ return { tools: () => one(undefined, {}) as Promise<Blueprint | { error: string }>, call: (name, args) => one(name, args), ears: new Set() };
30
+ }
31
+
32
+ export function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving> {
33
+ return mcpSide(owner(dir, ward, via), transport);
73
34
  }
package/mcp/quo-mcp.md CHANGED
@@ -23,6 +23,8 @@ and needs no translation code beyond an envelope:
23
23
  | server | an avatar's side |
24
24
  | session | a standing the avatar holds, persistent across sessions |
25
25
  | tools/list | the empty ask on that standing: describe for this asker |
26
+ | the `describe` tool | the empty ask itself, first in the list, so the notes of the |
27
+ | | describe are readable and not only its asks |
26
28
  | tool name, inputSchema | `asks[].name`, `asks[].input`, verbatim |
27
29
  | tools/call | a named ask with args |
28
30
  | result content | the answer object, as JSON |
@@ -198,23 +200,20 @@ the device and crosses only the local socket.
198
200
 
199
201
  ### A model as the owner
200
202
 
201
- An **owner pilot** is an MCP server over stdio that reaches a ward's owner
202
- asks, either as the root through the daemon's socket on the device, or as an
203
- owner the root invited, through a standing over the sealed door, and exposes
204
- four tools and no more:
205
-
206
- ```
207
- boot({ key, class, public? })
208
- invite({ being, id })
209
- knock({ being | { boot, key }, id, invitation, method?, args? })
210
- remove({ being, id })
211
- ```
212
-
213
- Plus one read, `census()`, which is the empty ask. Every call is logged
214
- with what it made. The pilot is handed to one local agent by the device's
215
- own rules. A remote pilot is a standing at the ward, and still not a route:
216
- a model without a ward of its own cannot be an owner anywhere. `quo pilot
217
- --via S` is that pilot: S is a standing the user being here holds at the
203
+ An **owner pilot** is the model side above, pointed at a ward instead of an
204
+ avatar. The ward is a being to her owner, and `packages/quo/SPEC.md` says her
205
+ describe carries her asks with a description and an input each, so the
206
+ pilot holds no list of its own: tools/list is the ward's describe, the
207
+ `describe` tool is the census, and a tool call is an owner ask, boot,
208
+ public, invite, knock or remove, in the ward's own words. It reaches those
209
+ asks either as the root through the daemon's socket on the device, or as an
210
+ owner the root invited, through a standing over the sealed door. It keeps
211
+ nothing and logs nothing: what a model did with it is in the host's own
212
+ transcript, and an invitation it was handed is in no file of the dock's.
213
+ The pilot is handed to one local agent by the device's own rules. A remote
214
+ pilot is a standing at the ward, and still not a route: a model without a
215
+ ward of its own cannot be an owner anywhere. `quo pilot --via S` is that
216
+ pilot: S is a standing the user being here holds at the
218
217
  far ward, taken when the root here knocked for her with an invitation the
219
218
  far root minted on its ward's pk, and every tool call is a sealed ask
220
219
  there, answered as the far ward answers an owner at its door.
@@ -259,4 +258,4 @@ On top of the shared ones in the trunk:
259
258
  under; governance, never permission.
260
259
  - **runner**: an inline MCP client for a model that speaks function calling;
261
260
  it drives the loop the MCP client would, over one conversation.
262
- - **owner pilot**: an MCP server over stdio exposing the four owner asks.
261
+ - **owner pilot**: the model side over a ward's owner asks, over stdio.
package/mcp/server.ts CHANGED
@@ -1,34 +1,48 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The model side as an MCP server: one avatar, one transport. The mapping is
3
- // total and needs nothing beyond this envelope. tools/list is her describe,
4
- // tools/call is a named ask on her one standing, a push is a logging
5
- // notification, and the three words for "no object" each cross as the table
6
- // in quo-mcp.md says. The transport is whatever the caller connected: the
2
+ // The model side as an MCP server: one subject, one transport. The subject
3
+ // is whoever answers the empty ask and a named one: an avatar on her one
4
+ // standing, or a ward's owner asks through the pilot. The mapping is total
5
+ // and needs nothing beyond this envelope. tools/list is her describe, and
6
+ // the empty ask itself is the first tool, so the notes of the describe are
7
+ // readable; tools/call is a named ask, a push is a logging notification,
8
+ // and the three words for "no object" each cross as the table in
9
+ // quo-mcp.md says. The transport is whatever the caller connected: the
7
10
  // SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
8
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
12
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
10
13
  import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
11
- import type { Blueprint, JsonObject } from '@quo-systems/quo';
12
- import type { Avatar } from '../beings/avatar.ts';
14
+ import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
13
15
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving } from '../beings/side.ts';
14
16
 
15
17
  export const NAME = 'quo';
16
18
  export const VERSION = '0.0.0';
17
19
 
18
- // Her describe, spoken as tools. Name, description and input are verbatim;
19
- // an output schema crosses when she declared one.
20
+ // What a side speaks for: the empty ask and a named one, and the ears a
21
+ // push reaches. An avatar is one; the owner's asks on a ward are another.
22
+ export type Subject = {
23
+ tools(): Promise<Blueprint | { error: string }>;
24
+ call(name: string, args: JsonObject): Promise<Answer>;
25
+ ears: Set<(object: JsonObject) => void>;
26
+ };
27
+
28
+ // The empty ask as a tool: her describe whole, asks and notes.
29
+ export const DESCRIBE: Tool = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
30
+
31
+ // Her describe, spoken as tools, the empty ask first. Name, description and
32
+ // input are verbatim; an output schema crosses when she declared one.
20
33
  export function tools(bp: Blueprint): Tool[] {
21
- return bp.asks.map((a) => {
34
+ const asks = bp.asks.map((a) => {
22
35
  const t: Tool = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
23
36
  if (a.description !== undefined) t.description = a.description;
24
37
  if (a.output !== undefined) t.outputSchema = { ...a.output, type: 'object' };
25
38
  return t;
26
39
  });
40
+ return [DESCRIBE, ...asks];
27
41
  }
28
42
 
29
43
  // `after` runs when a call is done: a harbor that must write what the ward
30
44
  // changed hooks it, since a same-ward ask never crosses the harbor.
31
- export async function mcpSide(avatar: Avatar, transport: Transport, after: () => Promise<void> = async () => {}): Promise<Serving> {
45
+ export async function mcpSide(avatar: Subject, transport: Transport, after: () => Promise<void> = async () => {}): Promise<Serving> {
32
46
  const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
33
47
 
34
48
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -38,7 +52,7 @@ export async function mcpSide(avatar: Avatar, transport: Transport, after: () =>
38
52
  });
39
53
 
40
54
  server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
41
- const w = word(await avatar.call(req.params.name, (req.params.arguments ?? {}) as JsonObject));
55
+ const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {}) as JsonObject));
42
56
  await after();
43
57
  if (w.word === 'object') {
44
58
  const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quo-systems/dock",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The dock: what every estate on Quo needs and nobody writes twice. A daemon and the quo command, the front desk, the user being and the avatar, harbors on disk, in a tab and on the edge, the model sides and the screen.",
5
5
  "keywords": [
6
6
  "quo",
@@ -54,10 +54,14 @@
54
54
  "./human/html": {
55
55
  "types": "./dist/human/html.d.ts",
56
56
  "default": "./dist/human/html.js"
57
+ },
58
+ "./harbor/edge/worker": {
59
+ "types": "./dist/harbor/edge/worker.d.ts",
60
+ "default": "./dist/harbor/edge/worker.js"
57
61
  }
58
62
  },
59
63
  "scripts": {
60
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
64
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp harbor/edge/platform.d.ts dist/harbor/edge/ && cp -R cli/estate dist/cli/",
61
65
  "test": "node --test \"test/*.test.ts\"",
62
66
  "check:terrain": "node --test \"test/terrain/*.test.ts\"",
63
67
  "prepublishOnly": "cd ../.. && npm run check && npm run check:terrain"