agents-city 0.5.2 → 0.5.4

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 (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +11 -0
  3. package/README.md +10 -0
  4. package/benchmarks/latency/fake-claude-cli.mjs +3 -2
  5. package/bin/serve.py +63 -6
  6. package/bin/test +2 -2
  7. package/bin/test-alcance.py +470 -0
  8. package/bin/test-arnes.py +109 -0
  9. package/bin/test-channel.py +295 -9
  10. package/bin/test-cities.py +57 -0
  11. package/bin/test-claude-runtime.py +17 -6
  12. package/bin/test-connect-client.mjs +8 -0
  13. package/bin/test-connect.py +1 -1
  14. package/bin/test-contracts.py +129 -3
  15. package/bin/test-e2e.py +371 -0
  16. package/bin/test-seat.py +154 -1
  17. package/bin/test-serve.py +83 -0
  18. package/city/web/dist-hall/hall.js +384 -3240
  19. package/docs/managed-connect.md +5 -0
  20. package/package.json +1 -1
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/channel/hub/lo-que-te-llega.ts +78 -0
  23. package/plugin/channel/hub/local-roads.ts +36 -0
  24. package/plugin/channel/hub/road-controller.ts +30 -5
  25. package/plugin/channel/local-hub.js +177 -105
  26. package/plugin/channel/local-hub.ts +8 -0
  27. package/plugin/channel/managed-connect-client.js +53 -3
  28. package/plugin/channel/managed-connect-client.manifest.json +2 -2
  29. package/plugin/channel/protocol.ts +24 -0
  30. package/plugin/commands/notice.md +14 -0
  31. package/plugin/hooks/ask-the-house.sh +25 -0
  32. package/plugin/hooks/hooks.json +10 -0
  33. package/plugin/hooks/notice-on-stop.sh +7 -1
  34. package/plugin/scripts/alcance.py +318 -0
  35. package/plugin/scripts/arnes.py +10 -1
  36. package/plugin/scripts/busca.py +13 -0
  37. package/plugin/scripts/card.py +40 -0
  38. package/plugin/scripts/cities.py +46 -4
  39. package/plugin/scripts/city-session.sh +80 -7
  40. package/plugin/scripts/domains.py +7 -0
  41. package/plugin/scripts/seat.py +33 -13
  42. package/plugin/skills/city/SKILL.md +12 -0
@@ -163,6 +163,11 @@ consuming another capability. A changed body with the same ID is rejected.
163
163
  were already admitted. Neither status means that a person or model has read or
164
164
  answered the message.
165
165
 
166
+ If an identified WebSocket admission temporarily returns `delivery_unavailable`
167
+ or `mailbox_full`, the client makes at most two more attempts with the same
168
+ encrypted envelope. Server-directed delays are capped and receive per-message
169
+ jitter, so a recovering lane is not hit again by every sender at once.
170
+
166
171
  Transport is therefore at-least-once with an idempotent local effect, not a
167
172
  fictional exactly-once network. Revocation removes the Road revision, queued
168
173
  ciphertext, capabilities and local cryptographic state. Reopening consumes new
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-city",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Run autonomous agent cities: one chair seat, role-aware repo agents, repo-owned skills, and explicit roads.",
5
5
  "bin": {
6
6
  "agents-city": "bin/agents-city.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "city",
3
3
  "displayName": "Agents City",
4
- "version": "0.5.2",
4
+ "version": "0.5.4",
5
5
  "description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
6
6
  "author": {
7
7
  "name": "jlcases",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What a city says reaches its seat — read from that city's own role file.
3
+ *
4
+ * Knowing a `ux` is on the bus does not tell you whether what you changed
5
+ * concerns them. The answer to that is written in their `roles/<role>.md`,
6
+ * under "What reaches you" — and until now that file never left the city that
7
+ * owned it. Each seat judged against its own copy of a catalogue, which is a
8
+ * guess wearing a document's clothes.
9
+ *
10
+ * So it travels, with three rules it cannot break:
11
+ *
12
+ * · The city that OWNS the role publishes it. Nobody describes somebody else.
13
+ * · It is bounded before it is published and again before it is read. Two
14
+ * processes, two chances to be wrong, and only one of them is ours.
15
+ * · It is a CLAIM, never an instruction. It ends up in another seat's model
16
+ * context, so it is neutralised the way road messages are.
17
+ */
18
+
19
+ import { readFileSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { stripSpecialTokens } from '../untrusted.js';
22
+
23
+ /** Long enough for a sentence or two of remit, short enough that nobody can
24
+ * paste a document into somebody else's context. */
25
+ export const LIMITE = 400;
26
+
27
+ /** The heading that names it, in the format the shipped role files use. */
28
+ const SECCION = /^##\s+What reaches you\s*$/i;
29
+
30
+ /**
31
+ * The "What reaches you" section of a role file, as one bounded line.
32
+ *
33
+ * Returns '' for anything it cannot read confidently — a missing file, a role
34
+ * with no such section, a section that is empty. Silence is the honest answer:
35
+ * "this role did not say" is a fact a reader can act on, and an invented
36
+ * summary is not.
37
+ */
38
+ export function loQueLlega(dataDir: string, seatRole: string): string {
39
+ if (!/^[a-z0-9-]{1,80}$/.test(seatRole)) return '';
40
+ let texto: string;
41
+ try {
42
+ texto = readFileSync(join(dataDir, 'roles', `${seatRole}.md`), 'utf8');
43
+ } catch {
44
+ return '';
45
+ }
46
+ return limpia(seccion(texto));
47
+ }
48
+
49
+ /** The body under the heading, up to the next heading or the end. */
50
+ function seccion(texto: string): string {
51
+ const lineas = texto.split('\n');
52
+ const desde = lineas.findIndex((l) => SECCION.test(l));
53
+ if (desde < 0) return '';
54
+ const cuerpo: string[] = [];
55
+ for (const linea of lineas.slice(desde + 1)) {
56
+ if (/^##\s/.test(linea)) break;
57
+ cuerpo.push(linea);
58
+ }
59
+ return cuerpo.join(' ');
60
+ }
61
+
62
+ /**
63
+ * One line, bounded, with chat-template delimiters defanged and control
64
+ * characters removed.
65
+ *
66
+ * The neutralising happens on the way OUT as well as on the way in. A city
67
+ * publishes this about itself, so a city with a tampered role file would
68
+ * otherwise be handing a forged turn to every neighbour that reads it — and
69
+ * "the reader will sanitise it" is not a property this side gets to assume.
70
+ */
71
+ export function limpia(bruto: string): string {
72
+ const plano = stripSpecialTokens(String(bruto ?? ''))
73
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
74
+ .replace(/\s+/g, ' ')
75
+ .trim();
76
+ if (!plano) return '';
77
+ return plano.length <= LIMITE ? plano : plano.slice(0, LIMITE - 1) + '…';
78
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import WebSocket from 'ws';
4
+ import { limpia as limpiaRemite } from './lo-que-te-llega.js';
4
5
  import { CityContext, runtimeDirForCity } from '../city-config.js';
5
6
  import { queueRoad } from '../delivery-queue.js';
6
7
  import { BusEnvelope, HubEndpoint, Road, randomId } from '../protocol.js';
@@ -30,6 +31,41 @@ export async function sendLocalRoad(
30
31
  }
31
32
  }
32
33
 
34
+ /**
35
+ * What the city at the far end says it is, read from its own endpoint file.
36
+ *
37
+ * The same file `localRoadOnline` already trusts to decide the city is alive.
38
+ * Nothing new is trusted here: if that file is good enough to say "this city
39
+ * exists and is running", it is good enough to say "and it is the design one".
40
+ */
41
+ export function localRoadPresenta(
42
+ context: CityContext,
43
+ road: Road,
44
+ ): { domain: string; seatRole: string; recibe: string } | null {
45
+ try {
46
+ const endpoint = JSON.parse(
47
+ readFileSync(join(runtimeDirForCity(context.appHome, road.id), 'endpoint.json'), 'utf8'),
48
+ ) as HubEndpoint;
49
+ if (endpoint.cityId !== road.id || endpoint.cityAddress !== road.address) return null;
50
+ const dicho = endpoint.presenta;
51
+ if (!dicho || typeof dicho.seatRole !== 'string' || typeof dicho.domain !== 'string')
52
+ return null;
53
+ // Untrusted text from another process: bounded and shaped before it is
54
+ // handed to anybody who might put it in a prompt.
55
+ const limpio = (valor: string): string =>
56
+ /^[a-z0-9-]{1,80}$/.test(valor.trim().toLowerCase()) ? valor.trim().toLowerCase() : '';
57
+ const seatRole = limpio(dicho.seatRole);
58
+ const domain = limpio(dicho.domain);
59
+ // Bounded again on the way in. The publisher bounds it too, and that is
60
+ // the point: the writer is another process, and a reader that trusts a
61
+ // length because the writer promised one is trusting the wrong side.
62
+ const recibe = limpiaRemite(typeof dicho.recibe === 'string' ? dicho.recibe : '');
63
+ return seatRole || domain ? { domain, seatRole, recibe } : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
33
69
  export function localRoadOnline(context: CityContext, road: Road): boolean {
34
70
  try {
35
71
  const endpoint = JSON.parse(
@@ -12,7 +12,7 @@ import { requireChair } from '../committee/guards.js';
12
12
  import { deliverApprovedReception, recordReceptionMessage } from '../reception.js';
13
13
  import { wrapUntrusted } from '../untrusted.js';
14
14
  import { EnvelopeRouter } from './envelopes.js';
15
- import { localRoadOnline, sendLocalRoad } from './local-roads.js';
15
+ import { localRoadOnline, localRoadPresenta, sendLocalRoad } from './local-roads.js';
16
16
  import { remoteRoadBridge } from './remote-roads.js';
17
17
  import { managedReceptionBridge } from '../managed-connect/reception-bridge.js';
18
18
 
@@ -132,10 +132,35 @@ export function roadController(context: CityContext, router: EnvelopeRouter) {
132
132
  ): Promise<unknown> => {
133
133
  if (name === 'road.roster') {
134
134
  requireChair(actor, role);
135
- return roads().map((road) => ({
136
- ...road,
137
- online: road.local ? localRoadOnline(context, road) : remote.online(road.address),
138
- }));
135
+ return roads().map((road) => {
136
+ const online = road.local ? localRoadOnline(context, road) : remote.online(road.address);
137
+ // What the far city says about itself wins over what whoever opened
138
+ // this road assumed — and the entry says which of the two you are
139
+ // reading, because "cpto because they say so" and "cpto because I
140
+ // wrote it down once" are not the same claim, and only one of them is
141
+ // safe to act on.
142
+ const dicho = road.local && online ? localRoadPresenta(context, road) : null;
143
+ // Per field, not per entry. A city can publish a good domain and a role
144
+ // that is not a role — and then one `segun` for the whole row would say
145
+ // "the city itself" over a role the city never validly claimed. Whoever
146
+ // is deciding "does this reach that role" needs to know whether THE
147
+ // ROLE is confirmed, and the domain's provenance does not answer that.
148
+ const suRol = dicho?.seatRole ?? '';
149
+ const suDominio = dicho?.domain ?? '';
150
+ const nota = 'this city’s own note';
151
+ const ella = 'the city itself';
152
+ return {
153
+ ...road,
154
+ ...(suRol ? { role: suRol } : {}),
155
+ ...(suDominio ? { domain: suDominio } : {}),
156
+ // What that city says reaches it. A claim, in their words, and the
157
+ // only thing here that turns "a ux is connected" into "this concerns
158
+ // them". Absent when they did not say.
159
+ ...(dicho?.recibe ? { recibe: dicho.recibe } : {}),
160
+ online,
161
+ segun: { role: suRol ? ella : nota, domain: suDominio ? ella : nota },
162
+ };
163
+ });
139
164
  }
140
165
  if (name === 'road.inbox') {
141
166
  requireChair(actor, role);