@quo-systems/dock 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/beings/avatar.ts +6 -2
  2. package/beings/carry.ts +101 -0
  3. package/beings/desk.ts +2 -2
  4. package/beings/index.ts +2 -0
  5. package/beings/link.ts +54 -0
  6. package/beings/look.ts +96 -0
  7. package/beings/quo-dock.md +129 -19
  8. package/beings/side.ts +10 -1
  9. package/beings/user.ts +31 -5
  10. package/cli/daemon.ts +98 -44
  11. package/cli/quo.ts +3 -2
  12. package/dist/beings/avatar.js +7 -2
  13. package/dist/beings/carry.d.ts +10 -0
  14. package/dist/beings/carry.js +106 -0
  15. package/dist/beings/desk.d.ts +1 -0
  16. package/dist/beings/desk.js +1 -1
  17. package/dist/beings/index.d.ts +2 -0
  18. package/dist/beings/index.js +2 -0
  19. package/dist/beings/link.d.ts +7 -0
  20. package/dist/beings/link.js +42 -0
  21. package/dist/beings/look.d.ts +27 -0
  22. package/dist/beings/look.js +71 -0
  23. package/dist/beings/side.d.ts +8 -1
  24. package/dist/beings/user.d.ts +26 -2
  25. package/dist/beings/user.js +32 -5
  26. package/dist/cli/daemon.d.ts +1 -1
  27. package/dist/cli/daemon.js +98 -44
  28. package/dist/cli/quo.js +3 -2
  29. package/dist/human/guest.d.ts +3 -0
  30. package/dist/human/guest.js +25 -0
  31. package/dist/human/html.d.ts +10 -2
  32. package/dist/human/html.js +61 -10
  33. package/dist/human/screen.d.ts +8 -3
  34. package/dist/human/screen.js +25 -6
  35. package/dist/human/tab.d.ts +2 -0
  36. package/dist/human/tab.js +127 -42
  37. package/dist/mcp/http.d.ts +4 -3
  38. package/dist/mcp/http.js +6 -6
  39. package/dist/mcp/oauth.d.ts +9 -4
  40. package/dist/mcp/oauth.js +15 -14
  41. package/dist/mcp/pilot.js +1 -1
  42. package/dist/mcp/server.d.ts +5 -10
  43. package/dist/mcp/server.js +35 -4
  44. package/dist/mcp/web/exchange.d.ts +5 -2
  45. package/dist/mcp/web/exchange.js +21 -7
  46. package/harbor/quo-harbor.md +37 -0
  47. package/human/guest.ts +26 -0
  48. package/human/html.ts +58 -10
  49. package/human/quo-human.md +123 -66
  50. package/human/screen.ts +28 -7
  51. package/human/tab.ts +153 -51
  52. package/mcp/http.ts +10 -9
  53. package/mcp/oauth.ts +20 -17
  54. package/mcp/pilot.ts +1 -1
  55. package/mcp/quo-mcp.md +18 -2
  56. package/mcp/server.ts +37 -18
  57. package/mcp/web/exchange.ts +23 -9
  58. package/package.json +2 -2
package/beings/avatar.ts CHANGED
@@ -51,8 +51,11 @@ export class Avatar extends Being {
51
51
  return this.join(inv as unknown as Invitation);
52
52
  }
53
53
 
54
- // Knock with an invitation, hand the user being one back so she can push,
55
- // and take her as `user`. The only moment a standing is born here.
54
+ // Knock with an invitation, hand the far being one back so she can push,
55
+ // and take her as `user`. The only moment a standing is born here. A being
56
+ // who answers the hello with an error object is a being who answered: she
57
+ // is taken all the same, and the way back she did not take is dropped, so
58
+ // any world's public being may admit a guest, hello or not.
56
59
  async join(invitation: Invitation): Promise<Blueprint | { error: string }> {
57
60
  if (this.standings[USER]) return this.tools();
58
61
  const mine = await this.invite(PUSHER);
@@ -62,6 +65,7 @@ export class Avatar extends Being {
62
65
  this.occupants.remove(PUSHER);
63
66
  return { error: isSilence(out) ? 'silence' : wordOf(out) };
64
67
  }
68
+ if (out !== null && typeof out === 'object' && !Array.isArray(out) && 'error' in out) this.occupants.remove(PUSHER);
65
69
  await this.take(USER, invitation);
66
70
  return this.tools();
67
71
  }
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A carrier: a being who shows the asks of the standings she holds as asks
3
+ // of her own, and forwards. Whoever she carries for sees, in one describe,
4
+ // what she can be asked and what every being she holds can be asked, each
5
+ // under that standing's name; asking one is an ask on that standing, in her
6
+ // name, so the far being sees her and never who is behind her. A user being
7
+ // carries acme for the human's devices; a house being could carry its
8
+ // rooms; nothing here knows what she is.
9
+ //
10
+ // A carried ask is `<id>-<name>`, and the mapping back is kept in her cells
11
+ // and never parsed from the name, so an id with a dash in it is no trouble.
12
+ // The notes say which asks are whose, `standings: { id: { asks, look? } }`,
13
+ // so a screen draws one section per standing and a model side keeps the
14
+ // flat list it needs. Her own asks come first and are never shadowed: a
15
+ // carried name that collides with one of hers is dropped.
16
+ //
17
+ // She asks a standing's describe again when its digest moved, and its
18
+ // `look` once per digest, and keeps both in her cells. So a describe of
19
+ // hers may cost one ask per stale standing, which is the price of a page
20
+ // that is never more than one ask behind.
21
+ import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
22
+ import type { Asker, Blueprint, JsonObject, OccupantRecord, Reply } from '@quo-systems/quo';
23
+ import { sanitise, type Look } from './look.ts';
24
+
25
+ type Carried = Record<string, { id: string; ask: string }>;
26
+ type Looks = Record<string, { digest: string | null; look: Look }>;
27
+
28
+ export class Carrier extends Being {
29
+ // Who sees what she carries. Nobody, until a subclass says who.
30
+ static carries(_occupant: OccupantRecord | undefined, _asker: Asker): boolean {
31
+ return false;
32
+ }
33
+ // Standings she never carries: a way back to a device is one, by the
34
+ // dock's own convention, and a subclass may name more.
35
+ static hidden(id: string): boolean {
36
+ return id.startsWith('to:');
37
+ }
38
+
39
+ private get carried(): Carried {
40
+ return ((this.cells.carried as Carried | undefined) ??= {});
41
+ }
42
+ private get looks(): Looks {
43
+ return ((this.cells.looks as Looks | undefined) ??= {});
44
+ }
45
+
46
+ // Her describe with her standings carried into it, for an asker who may
47
+ // see them. Every other asker gets her describe alone.
48
+ async blueprint(asker: Asker): Promise<Blueprint> {
49
+ const C = this.constructor as typeof Carrier;
50
+ const bp = this.describe(asker);
51
+ if (!C.carries(this.occupant(asker), asker)) return bp;
52
+ const own = new Set(bp.asks.map((a) => a.name));
53
+ const carried: Carried = {};
54
+ const groups: Record<string, { asks: string[]; look?: Look }> = {};
55
+ for (const [id, rec] of Object.entries(this.cells.standings)) {
56
+ if (C.hidden(id)) continue;
57
+ const st = this.standings[id];
58
+ if (!st) continue;
59
+ if (!rec.blueprint || rec.seen !== rec.digest) await st.ask(); // her ward writes the record
60
+ if (!rec.blueprint) continue;
61
+ const names: string[] = [];
62
+ for (const a of rec.blueprint.asks) {
63
+ if (a.name === 'look') continue;
64
+ const name = `${id}-${a.name}`;
65
+ if (own.has(name)) continue;
66
+ carried[name] = { id, ask: a.name };
67
+ names.push(name);
68
+ bp.asks.push({ ...a, name });
69
+ }
70
+ const group: { asks: string[]; look?: Look } = { asks: names };
71
+ if (rec.blueprint.asks.some((a) => a.name === 'look')) {
72
+ const kept = this.looks[id];
73
+ if (!kept || kept.digest !== rec.digest) {
74
+ const l = await st.ask('look');
75
+ this.looks[id] = { digest: rec.digest, look: isSilence(l) || isWord(l) ? {} : sanitise(l) };
76
+ }
77
+ group.look = this.looks[id]!.look;
78
+ }
79
+ groups[id] = group;
80
+ }
81
+ for (const id of Object.keys(this.looks)) if (!(id in groups)) delete this.looks[id];
82
+ this.cells.carried = carried;
83
+ const notes = bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? bp.notes : {};
84
+ return { asks: bp.asks, notes: { ...notes, standings: groups as unknown as JsonObject } };
85
+ }
86
+
87
+ override async answer(asker: Asker, method?: string, args: JsonObject = {}): Promise<Reply> {
88
+ if (method === undefined) return this.blueprint(asker);
89
+ const C = this.constructor as typeof Carrier;
90
+ const to = Object.hasOwn(this.carried, method) ? this.carried[method] : undefined;
91
+ if (to && C.carries(this.occupant(asker), asker)) {
92
+ const st = this.standings[to.id];
93
+ if (!st) return { error: 'unknown ask' };
94
+ const out = await st.ask(to.ask, args);
95
+ if (isSilence(out)) return out;
96
+ if (isWord(out)) return { error: wordOf(out) };
97
+ return out;
98
+ }
99
+ return super.answer(asker, method, args);
100
+ }
101
+ }
package/beings/desk.ts CHANGED
@@ -12,7 +12,7 @@ import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
12
12
  import type { Asker, Json, JsonObject } from '@quo-systems/quo';
13
13
 
14
14
  export type Proof = { kind: string; [more: string]: Json };
15
- export type Verified = { user: string; client: string; wake?: boolean };
15
+ export type Verified = { user: string; client: string; wake?: boolean; reach?: boolean };
16
16
  export type Verifier = (proof: Proof) => Promise<Verified | null> | Verified | null;
17
17
 
18
18
  export class Desk extends Being {
@@ -38,7 +38,7 @@ export class Desk extends Being {
38
38
  if (!who) return { error: 'refused' };
39
39
  const user = this.standings[`user:${who.user}`];
40
40
  if (!user) return { error: 'no such user' };
41
- const inv = await user.ask('device', who.wake === true ? { client: who.client, wake: true } : { client: who.client });
41
+ const inv = await user.ask('device', { client: who.client, ...(who.wake === true ? { wake: true } : {}), ...(who.reach === true ? { reach: true } : {}) });
42
42
  if (isSilence(inv)) return { error: 'silence' };
43
43
  if (isWord(inv)) return { error: wordOf(inv) };
44
44
  return inv;
package/beings/index.ts CHANGED
@@ -3,3 +3,5 @@
3
3
  export { User, DESK } from './user.ts';
4
4
  export { Desk, type Proof, type Verified, type Verifier } from './desk.ts';
5
5
  export { Avatar, USER, PUSHER } from './avatar.ts';
6
+ export { Carrier } from './carry.ts';
7
+ export { sanitise, hint, hintFor, groups, type Look, type Hint } from './look.ts';
package/beings/link.ts ADDED
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A link: a world's page with an invitation in the fragment. The page is the
3
+ // hint, where the world lives; the fragment is the invitation, which never
4
+ // leaves the browser: a query string reaches the server, its logs and every
5
+ // referer, and an invitation is a capability. One reserved key, `quo`, the
6
+ // one word no page uses for anything else, and the value is the invitation
7
+ // compact: `ward.heir.secret`, three hex strings, or `ward` alone for a
8
+ // world's public being. The dock's human module reads it, strips it before
9
+ // any page code runs, and joins.
10
+ import type { Invitation } from '@quo-systems/quo';
11
+
12
+ export const KEY = 'quo';
13
+ const HEX = (n: number) => `[0-9a-f]{${n}}`;
14
+ const FULL = new RegExp(`^(${HEX(128)})\\.(${HEX(64)})\\.(${HEX(64)})$`);
15
+ const PUBLIC = new RegExp(`^${HEX(128)}$`);
16
+
17
+ export function format(inv: Invitation): string {
18
+ return inv.heir && inv.secret ? `${inv.ward}.${inv.heir}.${inv.secret}` : inv.ward;
19
+ }
20
+
21
+ // The invitation in a fragment, or null. Anything else in the fragment is
22
+ // the page's own and is left alone.
23
+ export function parse(hash: string): Invitation | null {
24
+ const m = /(?:^#|&)quo=([^&]+)/.exec(hash.startsWith('#') ? hash : `#${hash}`);
25
+ if (!m) return null;
26
+ const v = decodeURIComponent(m[1]!);
27
+ const full = FULL.exec(v);
28
+ if (full) return { ward: full[1]!, heir: full[2]!, secret: full[3]! };
29
+ return PUBLIC.test(v) ? { ward: v } : null;
30
+ }
31
+
32
+ // The fragment without the invitation, so the page keeps whatever else it
33
+ // put there and the capability is gone from the address bar.
34
+ export function strip(hash: string): string {
35
+ const rest = (hash.startsWith('#') ? hash.slice(1) : hash)
36
+ .split('&')
37
+ .filter((p) => !p.startsWith(`${KEY}=`))
38
+ .join('&');
39
+ return rest ? `#${rest}` : '';
40
+ }
41
+
42
+ export function link(page: string, inv: Invitation): string {
43
+ return `${page}#${KEY}=${format(inv)}`;
44
+ }
45
+
46
+ // Whether an answer is an invitation: a ward pk, and a heir with its secret
47
+ // or neither. What a guest's form answers with to be let in.
48
+ export function isInvitation(v: unknown): v is Invitation {
49
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
50
+ const o = v as Record<string, unknown>;
51
+ if (typeof o.ward !== 'string' || !PUBLIC.test(o.ward)) return false;
52
+ const heir = typeof o.heir === 'string', secret = typeof o.secret === 'string';
53
+ return (heir && secret) || (!heir && !secret && !('heir' in o) && !('secret' in o));
54
+ }
package/beings/look.ts ADDED
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A look: what a being may say about how she is presented, as values and
3
+ // no more. One optional ask, `look`, answers it, and every side reads the
4
+ // part it understands: a screen reads the tokens and paints her section, a
5
+ // model side reads the hints per ask and writes them as tool annotations.
6
+ // Nothing here is capability, so nothing here is in a blueprint or a digest,
7
+ // and Quo never sees it. The vocabulary is closed: a token that is not
8
+ // listed does not exist, and one that fails its shape is dropped, not
9
+ // fixed. A being cannot paint outside her section or reach a server
10
+ // through a look, because no token can carry a URL, a stylesheet or code.
11
+ import type { Blueprint, Json, JsonObject } from '@quo-systems/quo';
12
+
13
+ // What a side may do with one ask. `title` is a human label instead of the
14
+ // name; `readOnly` says the ask changes nothing, so a screen may run it on
15
+ // its own and a model side marks it so; `destructive` asks for care;
16
+ // `idempotent` says asking twice is asking once; `icon` is one short string
17
+ // for a button. A being who marks an ask read-only and then writes has lied
18
+ // to her own page, and nothing enforces it, the way nothing enforces that
19
+ // her empty ask is safe to repeat.
20
+ export type Hint = { title?: string; readOnly?: boolean; destructive?: boolean; idempotent?: boolean; icon?: string };
21
+ export type Look = {
22
+ name?: string;
23
+ logo?: string; // an image as a data URI, never a URL
24
+ accent?: string; // hex colours
25
+ background?: string;
26
+ foreground?: string;
27
+ font?: string; // a font stack, plain characters
28
+ radius?: number; // 0 to 40, in px
29
+ order?: string[]; // her asks in the order she wants them shown
30
+ asks?: Record<string, Hint>;
31
+ };
32
+
33
+ const COLOUR = /^#[0-9a-fA-F]{3,8}$/;
34
+ const FONT = /^[\w\s,'"-]{1,80}$/;
35
+ const LOGO = /^data:image\/(svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/=]{1,40000}$/;
36
+ const NAME = /^[^<>&]{1,60}$/;
37
+ const ICON = /^[^<>&"']{1,8}$/;
38
+
39
+ const str = (v: Json | undefined, re: RegExp): string | undefined => (typeof v === 'string' && re.test(v) ? v : undefined);
40
+
41
+ // The look as a side may use it: every token held to its shape, everything
42
+ // else gone. A value that is not an object is no look at all.
43
+ export function sanitise(v: Json | undefined): Look {
44
+ if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) return {};
45
+ const l: Look = {};
46
+ const name = str(v.name, NAME);
47
+ if (name !== undefined) l.name = name;
48
+ const logo = str(v.logo, LOGO);
49
+ if (logo !== undefined) l.logo = logo;
50
+ for (const k of ['accent', 'background', 'foreground'] as const) {
51
+ const c = str(v[k], COLOUR);
52
+ if (c !== undefined) l[k] = c;
53
+ }
54
+ const font = str(v.font, FONT);
55
+ if (font !== undefined) l.font = font;
56
+ if (typeof v.radius === 'number' && Number.isFinite(v.radius) && v.radius >= 0 && v.radius <= 40) l.radius = Math.round(v.radius);
57
+ if (Array.isArray(v.order)) l.order = v.order.filter((x): x is string => typeof x === 'string');
58
+ if (v.asks !== null && typeof v.asks === 'object' && !Array.isArray(v.asks)) {
59
+ const asks: Record<string, Hint> = {};
60
+ for (const [ask, h] of Object.entries(v.asks as JsonObject)) {
61
+ if (h === null || typeof h !== 'object' || Array.isArray(h)) continue;
62
+ const hint: Hint = {};
63
+ const title = str(h.title, NAME);
64
+ if (title !== undefined) hint.title = title;
65
+ const icon = str(h.icon, ICON);
66
+ if (icon !== undefined) hint.icon = icon;
67
+ for (const k of ['readOnly', 'destructive', 'idempotent'] as const) if (typeof h[k] === 'boolean') hint[k] = h[k];
68
+ if (Object.keys(hint).length) asks[ask] = hint;
69
+ }
70
+ if (Object.keys(asks).length) l.asks = asks;
71
+ }
72
+ return l;
73
+ }
74
+
75
+ // The one hint a side reads first: whether this ask is in the look at all.
76
+ export const hint = (l: Look | undefined, name: string): Hint => l?.asks?.[name] ?? {};
77
+
78
+ // A carrier's notes say which asks are a standing's, and how she looks:
79
+ // `standings: { id: { asks: [name], look? } }`. Every side reads them here.
80
+ export type Group = { asks?: string[]; look?: Json };
81
+ export function groups(bp: Blueprint | null): Record<string, Group> {
82
+ const n = bp?.notes;
83
+ const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
84
+ return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? (g as Record<string, Group>) : {};
85
+ }
86
+
87
+ // The hint for one ask, hers or a standing's: a carried ask is looked up
88
+ // under its bare name in that standing's look.
89
+ export function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint {
90
+ for (const [id, g] of Object.entries(groups(bp))) {
91
+ if (!g.asks?.includes(name)) continue;
92
+ const prefix = `${id}-`;
93
+ return hint(sanitise(g.look), name.startsWith(prefix) ? name.slice(prefix.length) : name);
94
+ }
95
+ return hint(mine, name);
96
+ }
@@ -102,7 +102,10 @@ that every arrival is named and judged.
102
102
  - **Front desk.** A being on the user's harbor that strangers and fresh
103
103
  clients meet first. It holds a standing to every user being and can ask
104
104
  one for a device invitation. It is the only being that is ever public, and
105
- only for the first hello.
105
+ only for the first hello. Public is not a kind of being: she is an ordinary
106
+ being who chose to describe herself to strangers and answer them, and what
107
+ she keeps is hers like anyone's. This one keeps little because that is the
108
+ desk's job, not because Quo asks it of her.
106
109
  - **Envoy.** A being whose methods call a non-Quo thing and whose describe
107
110
  is that thing's surface. A SaaS, an MCP server someone else runs, an
108
111
  Arduino behind serial: each enters a world as a being like any other.
@@ -112,6 +115,12 @@ that every arrival is named and judged.
112
115
  - **Client identity.** What a credential exchange establishes: this human at
113
116
  this browser, this OAuth client for this human, this process on this
114
117
  machine. One client identity is one avatar, for life.
118
+ - **Carrier.** A being who shows the asks of the standings she holds as asks
119
+ of her own and forwards. The user being is one: a device the human let
120
+ reach sees acme's asks on her, and acme sees her.
121
+ - **Look.** What a being may say about how she is presented, as values: a
122
+ name, a logo, colours, a font, and a hint per ask. One optional ask,
123
+ `look`, answers it, and every side reads the part it understands.
115
124
  - **Route.** A hostname a reverse proxy sends to one process: `web.`, `quo.`,
116
125
  `mcp.`. Routes are deployment, never boundaries.
117
126
 
@@ -122,7 +131,7 @@ One droplet, one harbor, three routes. Every other placement is a subset.
122
131
  ```
123
132
  acme.com, or razvan.com: the dialable part of an estate
124
133
  reverse proxy
125
- web. serves the bundle, runs the credential exchange, ends with an invitation
134
+ web. one page per world, /<ward>, the credential exchange, ends with an invitation
126
135
  quo. the harbor's socket door: the rendezvous for every dialer
127
136
  mcp. the model side over HTTP, a credential exchange in front
128
137
  quo serve: one process, one harbor, the ask pointer
@@ -211,6 +220,63 @@ UI and then a tool layer: they build asks, both fall out, and neither side
211
220
  can do a thing the other cannot, because the gate is one decision for
212
221
  describe and for dispatch.
213
222
 
223
+ ## Carrying
224
+
225
+ A device sees one describe, its avatar's one standing, and the human's
226
+ world is many standings: acme, the calendar, the house. The user being
227
+ carries them. Her describe for a device the human allowed to reach shows,
228
+ after her own asks, every ask of every standing she holds, each named
229
+ `<id>-<name>`, and answering one is an ask on that standing in her name. Acme
230
+ sees her id and never the device; the device sees acme without holding a
231
+ key anywhere; the human removes acme once, at her, and every device loses
232
+ it at once. Invariant 1 holds as written: the occupant still holds one
233
+ standing.
234
+
235
+ Carrying is a base, `beings/carry.ts`, and nothing in it knows what a user
236
+ is: a house being could carry its rooms, a company its suppliers. A subclass
237
+ says who may see what she carries, and the user being says a device with the
238
+ `reach` note, written when the human ticked the box at the exchange. The
239
+ notes of a carrier's describe say which asks are whose,
240
+ `standings: { id: { asks, look? } }`, so a screen draws one section per
241
+ standing and a model side keeps the flat list it needs; the mapping back
242
+ from a carried name to the standing is in her cells and never parsed from
243
+ the name. Her own asks come first and are never shadowed. She asks a
244
+ standing's describe again when its digest moved and its `look` once per
245
+ digest, so a describe of hers may cost one ask per stale standing, the price
246
+ of a page that is never more than one ask behind.
247
+
248
+ ## The look
249
+
250
+ Three sides, one describe, and nothing hard-wired for any of them. A being
251
+ who wants to say how she is presented answers one more ask, `look`, with
252
+ values and no more, and every side reads the part it understands. The
253
+ vocabulary is closed, in `beings/look.ts`, and a token that fails its shape
254
+ is dropped, not fixed:
255
+
256
+ | token | shape | a screen | a model side |
257
+ | ---------------------- | ------------------------------------- | --------------------------------- | -------------------------- |
258
+ | name | text, no tags | her heading | nothing |
259
+ | logo | an image as a data URI, never a URL | beside the heading | nothing |
260
+ | accent, background, fg | hex colours | CSS variables on her section only | nothing |
261
+ | font | a font stack, plain characters | her section | nothing |
262
+ | radius | 0 to 40 | her corners | nothing |
263
+ | order | her ask names | which come first | tool order |
264
+ | asks.NAME.title | text | the button | the tool's title |
265
+ | asks.NAME.icon | one short string | on the button | nothing |
266
+ | asks.NAME.readOnly | boolean | run on open, nothing to type | `readOnlyHint` |
267
+ | asks.NAME.destructive | boolean | ask once more | `destructiveHint` |
268
+ | asks.NAME.idempotent | boolean | offer a retry after silence | `idempotentHint` |
269
+
270
+ A look is not capability: it is never in a blueprint or a digest, Quo never
271
+ sees it, and a being without one renders and lists exactly as before. The
272
+ `look` ask itself is presentation and is neither a form nor a tool. A
273
+ carrier keeps each standing's look beside her asks in the notes, so acme's
274
+ section on the human's page is painted as acme asked, inside that section
275
+ and nowhere else. No token can carry a URL, a stylesheet or code, so a far
276
+ being cannot paint over the page or reach a server through it. A being who
277
+ marks an ask read-only and then writes has lied to her own page, and nothing
278
+ enforces it, the way nothing enforces that her empty ask is safe to repeat.
279
+
214
280
  ## Asks only
215
281
 
216
282
  There is no listening in Quo, and none under `packages/dock/`. A being who wants
@@ -219,8 +285,8 @@ whenever something happens. An event is an ask in the other direction, and
219
285
  it is the same act next door and across planets; only latency and
220
286
  reachability change. It follows that two beings never share memory, not
221
287
  even in one ward: a callback, a stream or a bus between them is a link that
222
- breaks the day one of them moves, and Quo exists so that nothing breaks
223
- when a being moves.
288
+ breaks the day one of them is somewhere else, and Quo exists so that nothing
289
+ breaks when she is. Only a ward moves; a being is placed once, at boot.
224
290
 
225
291
  Two consequences every side obeys:
226
292
 
@@ -258,20 +324,26 @@ thin as one call. Every route ends the same way:
258
324
  1. The route authenticates by its own means. Password, passkey, OAuth, a
259
325
  bearer token from a client, a nonce from a local process.
260
326
  2. The route asks the front desk `device({ proof })` and receives an
261
- invitation.
327
+ invitation. Which desk is which world: a harbor holds many wards, and
328
+ the human chooses the world at the allow page when there is more than
329
+ one; the grant remembers it.
262
330
  3. The route hands the invitation to the avatar it is about to boot, or to
263
331
  the one already booted for this client identity if it is a reconnect, in
264
332
  which case nothing is handed and the existing standing is used.
265
333
  4. The avatar knocks. The heir dies. From here there is no token anywhere.
266
334
 
267
- The human decides one more thing at step 1, on the same page that names
268
- the identity: whether this device may **wake** the human's other devices.
269
- The word travels with the proof, the user being writes it as a note on the
270
- occupant she mints, and her gate reads the note: `push` is shown to the
271
- desk and to a device with that note, and to nobody else. A device that may
272
- wake can hand an agent an event through the user being, and she sees who
273
- did. The note is written when the occupant is minted; to change it, remove
274
- the occupant and allow again, because reconnect mints nothing.
335
+ The human decides two more things at step 1, on the same page that names
336
+ the identity: whether this device may **wake** the human's other devices,
337
+ and whether it may **reach** what she holds. Each word travels with the
338
+ proof, the user being writes it as a note on the occupant she mints, and her
339
+ gate reads the note: `push` is shown to the desk and to a device with the
340
+ `wake` note, and her standings are carried for a device with the `reach`
341
+ note, and for nobody else. A device that may wake can hand an agent an event
342
+ through the user being, and she sees who did; a device that may reach sees
343
+ acme on her, and a model sees acme only because the human said it may. A tab
344
+ opened with the owner password reaches, since the owner opened it. The notes
345
+ are written when the occupant is minted; to change one, remove the occupant
346
+ and allow again, because reconnect mints nothing.
275
347
 
276
348
  Tokens, cookies and grants exist for the length of step 1 and are never
277
349
  stored on a Quo side. Revocation is `occupants.remove(id)` on the user being:
@@ -292,7 +364,7 @@ quo pilot [--dir D] [--via S] the owner pilot over stdio: the ward's describe as
292
364
  quo side [--dir D] --as NAME an avatar over stdio for a local client; NAME is the client identity
293
365
  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
366
  quo census [--dir D] print the census once and exit
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
367
+ quo boot|public|invite|knock|remove|unboot the six owner asks from a shell, for scripts and for agents under cron; --via S sends them to another ward
296
368
  quo reach <pk> <url> a hint for the harbor's directory: that world's ward lives at that quo. route
297
369
  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
298
370
  ```
@@ -405,6 +477,8 @@ class bodies and placement, and reaches every piece through the dock.
405
477
  | side | an avatar speaking to someone outside | screen, MCP server, runner, agent |
406
478
  | proof | turning a credential into an invitation | one verifier, a daemon nonce, three kinds |
407
479
  | envoy | a non-Quo surface as a being | none built: a name until its first user |
480
+ | carrier | a being showing her standings as her own | the user being; any estate being that extends it |
481
+ | look | how a being is presented, as values | read by the screen and the model side |
408
482
 
409
483
  Two rules keep them general:
410
484
 
@@ -474,12 +548,20 @@ above or sending us back to fix it:
474
548
  becomes an invitation the user being minted, the avatar knocks and takes
475
549
  her, and her tool list equals the user being's describe for her;
476
550
  reconnect mints nothing; two clients see two tool lists; a push is a
477
- reverse ask that reaches the side; remove is revocation. The ids that
551
+ reverse ask that reaches the side; revocation is her sixth ask,
552
+ `forget({ client })`, the desk's alone. The ids that
478
553
  carry the conventions: the desk is the occupant the root named `desk`
479
554
  at the user being; the desk holds each user being as `user:<name>`; the
480
555
  user being holds each device's way back as `to:<client>`; the avatar
481
556
  holds the user being as `user` and is pushed to as `user:push`, two ids
482
557
  because standings and occupants share one namespace.
558
+ A device is two relations, because a relation in Quo is one direction:
559
+ its way in, the occupant, and her way back, `to:<client>`. Neither
560
+ implies the other, and the door will never join them. She joins them
561
+ herself, as her own rule and not Quo's: she does not push to a device
562
+ she no longer admits, and `forget` drops both in one act, so no side
563
+ has to remember there were two. Before this a revoked device kept
564
+ receiving every push.
483
565
  3. Done. The side in `packages/dock/beings/side.ts`: a function of the avatar
484
566
  that runs until closed, and `word`, which tells the three words and the
485
567
  object apart before any side renders them. The MCP server in
@@ -642,8 +724,7 @@ above or sending us back to fix it:
642
724
  a terminal, the same pipe as `quo side` with a model named in the
643
725
  hello, the daemon running the runner beside the avatar, a line in a
644
726
  turn and the final text a line out, proven with a scripted endpoint
645
- as a child process in `packages/dock/test/cli.test.ts`. The gate profile per
646
- client identity the road lists is defined nowhere and was not built.
727
+ as a child process in `packages/dock/test/cli.test.ts`.
647
728
  18. The edge terrain: Done. `packages/dock/harbor/edge/`, the core over a Durable
648
729
  Object: its storage the store with the seed sealed under a platform
649
730
  secret, the deployed worker the code, the object the lease; reached by
@@ -708,13 +789,41 @@ above or sending us back to fix it:
708
789
  overwrite a package or take a name that is no domain, and its droplet
709
790
  boots, `quo init` into it and a daemon answering the census.
710
791
 
792
+ 23. The human side proper, in flight. What the twelfth sitting decided, in
793
+ the order it lands: **carrying**, `beings/carry.ts`, the user being on
794
+ it, a device seeing acme's asks on her under the `reach` note from the
795
+ exchange, proven in `test/human.test.ts` and `test/mcp.test.ts` on the
796
+ memory harbor with a shop that has a look; **the look**, `beings/look.ts`,
797
+ one closed vocabulary read by the screen as a section per standing under
798
+ its tokens and by the model side as titles and annotations, the `look`
799
+ ask neither a form nor a tool. Then **worlds**: a world is a ward with a
800
+ public being, its guest page is that being's describe rendered by the
801
+ screen, whatever class she is, `human/guest.ts`, and a form whose answer
802
+ is an invitation is the way in; worlds have addresses, `web./<ward>`,
803
+ `web./` the list, a hostname per world the proxy's line; a link is the
804
+ page plus the invitation in the fragment under the reserved key `quo`,
805
+ never in a query string, `beings/link.ts`; the tab is a browser of worlds,
806
+ one ward per joined world, a switcher over them, and a content security
807
+ policy on its shell; the avatar joins a being with no `hello` all the
808
+ same. Proven on the memory harbor with a door that is not a desk and in
809
+ Chromium against the desk. Then **the routes read again against the
810
+ same pieces**: a grant names the world the human allowed the client
811
+ into, the allow page offers the harbor's worlds when it has more than
812
+ one, a session is an identity's in a world, and revocation is per world;
813
+ the MCP endpoint stays one per harbor, `/mcp`, since the grant says
814
+ which world. Done. What the lab taught before any of it was
815
+ written: a standing's blueprint is empty after take until the empty ask
816
+ fills it, so a carrier refreshes on describe; and a dotted name is
817
+ refused by the model side, so a carried name takes a dash.
818
+
711
819
  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.
820
+ the documents and the folders agree up to step 22, and step 23 is in flight
821
+ as its line says. `next.md` is where the next sitting starts.
714
822
 
715
823
  ## Glossary
716
824
 
717
825
  - **avatar**: a being that is someone's voice from outside Quo.
826
+ - **carrier**: a being who shows her standings' asks as her own and forwards.
718
827
  - **client identity**: what a credential exchange establishes; one avatar.
719
828
  - **envoy**: a being whose methods call a non-Quo thing.
720
829
  - **dock**: what every estate needs and nobody writes twice, `packages/dock/`,
@@ -725,6 +834,7 @@ today. `next.md` is where the next sitting starts.
725
834
  - **front desk**: the being every fresh client meets; hands out invitations
726
835
  minted by user beings.
727
836
  - **gate**: `for(occupant, asker)`; the whole permission model.
837
+ - **look**: how a being is presented, as values; one optional ask.
728
838
  - **owner pilot**: `quo pilot`, the model side over a ward's owner asks, reaching
729
839
  the daemon's socket; with `--via`, a ward elsewhere through a standing.
730
840
  - **daemon**: `quo serve`, the one process on a device that holds a harbor.
package/beings/side.ts CHANGED
@@ -6,13 +6,22 @@
6
6
  // a screen, an MCP server, a runner, an event-driven agent. None knows
7
7
  // another exists, and none of this file names a platform.
8
8
  import { isSilence, isUnreached, isWord, wordOf } from '@quo-systems/quo';
9
- import type { Answer, Json, JsonObject, WordName } from '@quo-systems/quo';
9
+ import type { Answer, Blueprint, Json, JsonObject, Wanted, WordName } from '@quo-systems/quo';
10
10
  import type { Avatar } from './avatar.ts';
11
11
 
12
12
  // A side, running: it holds the avatar until closed.
13
13
  export type Serving = { close(): Promise<void> };
14
14
  export type Side = (avatar: Avatar) => Promise<Serving>;
15
15
 
16
+ // What a side speaks for: the empty ask and a named one, and the ears a
17
+ // push reaches. An avatar on her one standing is one; the owner's asks on a
18
+ // ward are another; a guest at a world's public being is a third.
19
+ export type Subject = {
20
+ tools(): Promise<Blueprint | { error: string }>;
21
+ call(name: string, args?: JsonObject, wanted?: Wanted): Promise<Answer>;
22
+ ears: Set<(object: JsonObject) => void>;
23
+ };
24
+
16
25
  // The three words for "no object", and the object itself, as every side must
17
26
  // tell them apart before rendering. An error object is an ordinary answer
18
27
  // that the side may mark; silence and unreached are not objects at all.