agents-city 0.5.10 → 0.5.11

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 (73) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +15 -13
  3. package/README.md +15 -13
  4. package/bin/agents-city.js +181 -56
  5. package/bin/hall +1 -1
  6. package/bin/serve.py +29 -28
  7. package/bin/setup.py +9 -7
  8. package/bin/test +6 -3
  9. package/bin/test-actualiza.py +16 -6
  10. package/bin/test-alcance.py +9 -7
  11. package/bin/test-arnes.py +7 -5
  12. package/bin/test-atajos.py +73 -9
  13. package/bin/test-broker.py +4 -4
  14. package/bin/test-cage.py +36 -8
  15. package/bin/test-card.py +11 -10
  16. package/bin/test-cities.py +38 -21
  17. package/bin/test-contracts.py +318 -150
  18. package/bin/test-doctor.py +5 -5
  19. package/bin/test-domains.py +4 -4
  20. package/bin/test-e2e.py +2 -2
  21. package/bin/test-exit.py +1 -1
  22. package/bin/test-hooks.py +227 -0
  23. package/bin/test-launch.py +110 -6
  24. package/bin/test-live-feed.py +3 -2
  25. package/bin/test-multiplexor.py +262 -0
  26. package/bin/test-seat.py +257 -239
  27. package/bin/test-security.py +18 -10
  28. package/bin/test-serve.py +41 -5
  29. package/bin/testlib.py +11 -0
  30. package/bus/scripts/test-hub.ts +34 -16
  31. package/city/scripts/history.py +1 -1
  32. package/city/scripts/seed.py +2 -2
  33. package/city/web/dist-hall/hall.js +5 -5
  34. package/city/web/src/hall.ts +6 -8
  35. package/docs/security.md +1 -1
  36. package/package.json +3 -2
  37. package/plugin/.claude-plugin/plugin.json +1 -1
  38. package/plugin/channel/adapter.js +64 -34
  39. package/plugin/channel/multiplexor.ts +93 -0
  40. package/plugin/channel/runtime/multiplexores.json +206 -0
  41. package/plugin/channel/terminal-delivery.ts +14 -33
  42. package/plugin/hooks/hook.py +557 -0
  43. package/plugin/hooks/hooks.json +12 -12
  44. package/plugin/scripts/apaga.py +8 -8
  45. package/plugin/scripts/atajos.py +79 -0
  46. package/plugin/scripts/cage.py +1 -1
  47. package/plugin/scripts/city-env.sh +12 -86
  48. package/plugin/scripts/city-runtime.sh +4 -86
  49. package/plugin/scripts/city-session.sh +8 -818
  50. package/plugin/scripts/city_env.py +168 -6
  51. package/plugin/scripts/doctor.py +5 -3
  52. package/plugin/scripts/launch.py +238 -34
  53. package/plugin/scripts/multiplexor.py +354 -0
  54. package/plugin/scripts/mux_herdr.py +275 -0
  55. package/plugin/scripts/report.py +1 -1
  56. package/plugin/scripts/roles.py +1 -1
  57. package/plugin/scripts/runtime_processes.py +15 -0
  58. package/plugin/scripts/runtimes.py +120 -0
  59. package/plugin/scripts/seat.py +23 -15
  60. package/plugin/scripts/sesion.py +870 -0
  61. package/plugin/scripts/tokens.py +3 -3
  62. package/plugin/scripts/ui.py +14 -3
  63. package/plugin/scripts/workspace.py +122 -16
  64. package/plugin/hooks/activity.sh +0 -6
  65. package/plugin/hooks/ask-the-house.sh +0 -30
  66. package/plugin/hooks/digging.sh +0 -68
  67. package/plugin/hooks/growth.sh +0 -42
  68. package/plugin/hooks/notice-on-pr.sh +0 -35
  69. package/plugin/hooks/notice-on-stop.sh +0 -86
  70. package/plugin/hooks/notice-pending.sh +0 -58
  71. package/plugin/hooks/solo-en-ciudad.sh +0 -23
  72. package/plugin/hooks/tokens.sh +0 -47
  73. package/plugin/hooks/who-does-this-concern.sh +0 -22
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "city",
11
11
  "source": "./plugin",
12
- "version": "0.5.10",
12
+ "version": "0.5.11",
13
13
  "description": "Operate one autonomous city seat, its domain, role, repo support agents, goal, recognised skills and explicit roads to other cities."
14
14
  }
15
15
  ]
package/README.es.md CHANGED
@@ -165,10 +165,9 @@ agents-city --version
165
165
  | Node.js 22.13 o posterior | paquete npm, bus WebSocket, recepción local y frontends |
166
166
  | npm | instalación y empaquetado |
167
167
  | Python 3 | Hall, onboarding, ciudades, mapas y utilidades |
168
- | bash | sesiones y launchers |
169
- | tmux | una ventana por asiento/repo; `seat` intenta instalarlo si falta |
170
- | macOS o Linux | plataformas nativas soportadas |
171
- | WSL | necesario en Windows porque Windows no incluye bash/tmux nativos |
168
+ | un servidor de ventanas | una ventana por asiento/agente: **tmux** en macOS y Linux, **herdr** en Windows; `seat` intenta instalarlo si falta |
169
+ | macOS, Linux o Windows | los tres de forma nativa. Cuatro comandos siguen necesitando shell — `city`, `demo`, `benchmark`, `test` y lo dicen por su nombre en Windows |
170
+ | bash | sólo para esos cuatro |
172
171
 
173
172
  Cada motor necesita además su propio CLI instalado y autenticado. Agents City no
174
173
  incluye ni suplanta las cuentas de Claude, Codex, OpenCode o Kimi.
@@ -340,15 +339,18 @@ agents-city shortcut --remove # quitarlo otra vez
340
339
  agents-city shortcut --to ~/bin # en otro sitio que no sea el escritorio
341
340
  ```
342
341
 
343
- **En Windows** la ciudad vive dentro de WSL, y un `~/Desktop` de WSL es el
344
- escritorio del home de Linux: uno que nadie mira nunca. Por eso el acceso directo
345
- se escribe en el escritorio **de Windows**, preguntándoselo a Windows en vez de
346
- adivinarlo a partir del nombre de usuario (un escritorio redirigido a OneDrive o
347
- a un perfil de dominio no está bajo `C:\Users\<nombre>\Desktop`). Es un `.lnk` de
348
- verdad, construido con la propia PowerShell de Windows para que pueda llevar un
349
- `.ico`, y arranca `wsl.exe` ejecutando el mismo comando en una shell de login. Si
350
- no hay interoperabilidad, se escribe un `.cmd` que también se abre con doble
351
- clic: la misma puerta, con icono genérico.
342
+ **En Windows** es un `.lnk` de verdad, construido con la propia PowerShell de
343
+ Windows para que pueda llevar un `.ico`, y ejecuta el mismo comando de la puerta
344
+ principal que cualquier otro escritorio. El escritorio se le pregunta a Windows
345
+ en vez de adivinarlo a partir del nombre de usuario: uno redirigido a OneDrive o
346
+ a un perfil de dominio no está bajo `C:\Users\<nombre>\Desktop`. Si no hay
347
+ PowerShell se escribe un `.cmd` que también se abre con doble clic: la misma
348
+ puerta, con icono genérico.
349
+
350
+ **Dentro de WSL** el acceso directo sigue cruzando de vuelta: un `~/Desktop` de
351
+ ahí es el escritorio del home de Linux, que nadie mira, así que el escritorio de
352
+ Windows recibe un `.lnk` que arranca `wsl.exe` con el mismo comando en una shell
353
+ de login.
352
354
 
353
355
  ### Qué se crea
354
356
 
package/README.md CHANGED
@@ -163,10 +163,9 @@ agents-city --version
163
163
  | Node.js 22.13 or later | npm package, WebSocket bus, local reception, and frontends |
164
164
  | npm | installation and packaging |
165
165
  | Python 3 | Hall, onboarding, cities, maps, and utilities |
166
- | bash | sessions and launchers |
167
- | tmux | one window per seat/repo; `seat` tries to install it when missing |
168
- | macOS or Linux | natively supported platforms |
169
- | WSL | required on Windows because native Windows has no bash/tmux |
166
+ | a window server | one window per seat/agent: **tmux** on macOS and Linux, **herdr** on Windows; `seat` tries to install it when missing |
167
+ | macOS, Linux or Windows | all three natively. Four commands still need a shell — `city`, `demo`, `benchmark`, `test` and say so by name on Windows |
168
+ | bash | only for those four |
170
169
  | bubblewrap | optional, Linux only: it IS the cage there. Without it agents run uncaged — see [the cage](#the-cage-the-broker-and-the-audit-chain) |
171
170
 
172
171
  Each runtime also needs its own installed and authenticated CLI. Agents City
@@ -336,14 +335,16 @@ agents-city shortcut --remove # take it off again
336
335
  agents-city shortcut --to ~/bin # somewhere other than the desktop
337
336
  ```
338
337
 
339
- **On Windows** the city lives inside WSL, and a `~/Desktop` in WSL is the Linux
340
- home's desktop which nobody ever looks at. So the shortcut is written to the
341
- **Windows** desktop instead, asked of Windows itself rather than guessed from a
342
- username (a desktop redirected to OneDrive or a domain profile is not under
343
- `C:\Users\<name>\Desktop`). It is a real `.lnk`, built through Windows' own
344
- PowerShell so it can carry an `.ico`, and it launches `wsl.exe` running the same
345
- command in a login shell. Where interop is unavailable, a double-clickable
346
- `.cmd` is written instead same door, plain icon.
338
+ **On Windows** it is a real `.lnk`, built through Windows' own PowerShell so it
339
+ can carry an `.ico`, running the same front-door command as every other desktop.
340
+ The desktop is asked of Windows itself rather than guessed from a username — one
341
+ redirected to OneDrive or a domain profile is not under `C:\Users\<name>\Desktop`.
342
+ Where PowerShell is unavailable a double-clickable `.cmd` is written instead:
343
+ same door, plain icon.
344
+
345
+ **Inside WSL** the shortcut still crosses back: a `~/Desktop` there is the Linux
346
+ home's desktop, which nobody looks at, so the Windows desktop gets a `.lnk` that
347
+ launches `wsl.exe` running the same command in a login shell.
347
348
 
348
349
  ### What gets created
349
350
 
@@ -989,7 +990,8 @@ script pretending:
989
990
  |---|---|---|
990
991
  | macOS | `.app` bundle running the city in Terminal | `.icns`, built with the system's `iconutil` |
991
992
  | Linux | `.desktop` entry, marked trusted where `gio` exists | `.png` under `XDG_DATA_HOME` |
992
- | Windows (WSL) | `.lnk` on the **Windows** desktop, launching `wsl.exe` | `.ico`, when PowerShell interop is reachable |
993
+ | Windows | `.lnk` on the desktop, running the front-door command | `.ico`, built by hand; a `.cmd` without PowerShell |
994
+ | Windows (inside WSL) | `.lnk` on the **Windows** desktop, launching `wsl.exe` | `.ico`, when PowerShell interop is reachable |
993
995
 
994
996
  All of them run the same line you would type, so the shortcut is a labelled
995
997
  button on the front door rather than a second way in. The icon is generated
@@ -3,9 +3,15 @@
3
3
  *
4
4
  * The same trick Claude Code uses — npm as the installer, the payload being
5
5
  * whatever the product actually is. Here the payload is the repo itself: the
6
- * Python that writes your files, the bash that builds your tmux session, and the
7
- * prebuilt map. Node is only the dispatcher, and it is guaranteed present because
8
- * npm just ran.
6
+ * Python that writes your files and builds your city, the Node that carries the
7
+ * bus, and the prebuilt map. Node is only the dispatcher, and it is guaranteed
8
+ * present because npm just ran.
9
+ *
10
+ * Every command used to go through a bash door, and fifteen of them were one
11
+ * `exec python3 x.py "$@"` line — a spelling of the interpreter, in the one
12
+ * language Windows does not have. They are named by their implementation now,
13
+ * and this file picks the interpreter, so what is left needing a shell is five
14
+ * commands rather than all of them.
9
15
  *
10
16
  * Deliberately not a port to JS: the shared modules (card, parcels, units, roles)
11
17
  * are the product's single source of truth and every door reads them — the
@@ -19,35 +25,114 @@ const fs = require('fs');
19
25
 
20
26
  const RAIZ = path.dirname(__dirname);
21
27
 
28
+ /* Every command, named by what it IS rather than by the door it used to have.
29
+ *
30
+ * A door that was one `exec python3 x.py "$@"` line is not a door: it is a
31
+ * spelling of the interpreter, and spelling it in bash is what kept this
32
+ * product off Windows for the fifteen commands that never needed a shell at
33
+ * all. So each entry says its kind, and this file picks the interpreter:
34
+ *
35
+ * py a Python module — everywhere
36
+ * node a Node entry point — everywhere; `env` first resolves the city's
37
+ * settings, which the bash door used to do by sourcing city-env.sh
38
+ * sh a door with shell logic of its own, and therefore POSIX for now
39
+ */
22
40
  const ORDENES = {
23
- hall: { que: ['bin/hall'], di: 'the town hall: manage your city from the browser' },
24
- seat: { que: ['bin/seat'], di: 'your chair: domain, seat role, repo-agent roles, goal, tmux' },
25
- cities: { que: ['bin/cities'], di: 'list, create or select your cities' },
26
- agents: { que: ['bin/agents'], di: 'list agents and manage their workspace mounts' },
27
- road: { que: ['bin/road'], di: 'connect two cities explicitly' },
28
- connect: { que: ['bin/connect'], di: 'pair this computer and open encrypted managed Roads' },
29
- bus: { que: ['bin/bus'], di: 'send information over those roads (seat only)' },
30
- committee: { que: ['bin/committee'], di: 'chair-mediated work with repo agents' },
31
- benchmark: { que: ['bin/benchmark'], di: 'offline stress/governance or opt-in live runtime latency' },
32
- logs: { que: ['bin/logs'], di: 'read or follow visible activity and operational diagnostics' },
33
- reset: { que: ['bin/reset'], di: 'reset one city to onboarding, recoverably' },
41
+ hall: { py: 'bin/hall', di: 'the town hall: manage your city from the browser' },
42
+ seat: {
43
+ py: 'plugin/scripts/seat.py',
44
+ di: 'your chair: domain, seat role, repo-agent roles, goal, tmux',
45
+ },
46
+ cities: { py: 'plugin/scripts/cities.py', di: 'list, create or select your cities' },
47
+ agents: {
48
+ py: 'plugin/scripts/workspace.py',
49
+ di: 'list agents and manage their workspace mounts',
50
+ },
51
+ road: { py: 'plugin/scripts/roads.py', di: 'connect two cities explicitly' },
52
+ connect: {
53
+ node: 'plugin/channel/managed-connect-cli.js',
54
+ di: 'pair this computer and open encrypted managed Roads',
55
+ },
56
+ bus: {
57
+ node: 'plugin/channel/client.js',
58
+ antes: ['bus'],
59
+ entorno: true,
60
+ di: 'send information over those roads (seat only)',
61
+ },
62
+ committee: {
63
+ node: 'plugin/channel/client.js',
64
+ antes: ['committee'],
65
+ entorno: true,
66
+ di: 'chair-mediated work with repo agents',
67
+ },
68
+ benchmark: {
69
+ sh: 'bin/benchmark',
70
+ di: 'offline stress/governance or opt-in live runtime latency',
71
+ },
72
+ logs: {
73
+ py: 'plugin/scripts/logs.py',
74
+ di: 'read or follow visible activity and operational diagnostics',
75
+ },
76
+ reset: { py: 'plugin/scripts/reset.py', di: 'reset one city to onboarding, recoverably' },
34
77
  uninstall: {
35
- que: ['bin/uninstall'],
78
+ py: 'plugin/scripts/desinstala.py',
36
79
  di: 'remove everything this wrote on this machine (previews first)',
37
80
  },
38
- skills: { que: ['bin/skills'], di: 'recognise the skills installed in each repo' },
39
- city: { que: ['bin/city'], di: 'draw your city' },
40
- shortcut: { que: ['bin/shortcut'], di: 'put a city on your desktop: icon, name, double-click' },
41
- demo: { que: ['bin/demo'], di: 'Aurora Games — see it working, no account' },
42
- setup: { que: ['bin/setup.py'], di: 'create or open a personal city' },
43
- report: { que: ['bin/report.py'], di: 'report growth from your folders' },
44
- tokens: { que: ['bin/tokens.py'], di: 'report token spend' },
45
- exit: { que: ['bin/exit'], di: 'close the day every session and agent, or one city' },
46
- doctor: { que: ['bin/doctor'], di: 'check this machine: tools, runtimes, cage, city, version' },
47
- update: { que: ['bin/update'], di: 'install the newest published version (--check just asks)' },
48
- test: { que: ['bin/test'], di: 'every check in the repo' },
81
+ skills: {
82
+ py: 'plugin/scripts/capabilities.py',
83
+ di: 'recognise the skills installed in each repo',
84
+ },
85
+ city: { sh: 'bin/city', di: 'draw your city' },
86
+ shortcut: {
87
+ py: 'plugin/scripts/atajos.py',
88
+ di: 'put a city on your desktop: icon, name, double-click',
89
+ },
90
+ demo: { sh: 'bin/demo', di: 'Aurora Games see it working, no account' },
91
+ setup: { py: 'bin/setup.py', di: 'create or open a personal city' },
92
+ report: { py: 'bin/report.py', di: 'report growth from your folders' },
93
+ tokens: { py: 'bin/tokens.py', di: 'report token spend' },
94
+ exit: {
95
+ py: 'plugin/scripts/apaga.py',
96
+ di: 'close the day — every session and agent, or one city',
97
+ },
98
+ doctor: {
99
+ py: 'plugin/scripts/doctor.py',
100
+ di: 'check this machine: tools, runtimes, cage, city, version',
101
+ },
102
+ update: {
103
+ py: 'plugin/scripts/actualiza.py',
104
+ di: 'install the newest published version (--check just asks)',
105
+ },
106
+ test: { sh: 'bin/test', di: 'every check in the repo' },
49
107
  };
50
108
 
109
+ /* Which Python. `python3` is the POSIX spelling and it is NOT what a Windows
110
+ * install from python.org gives you — that is `python` and the `py` launcher,
111
+ * and the Microsoft Store's `python3.exe` is a stub that opens the Store when
112
+ * nothing is installed. So: ask, in order, and take the first that answers with
113
+ * a version. Resolved once per run.
114
+ */
115
+ let elInterprete;
116
+ function interprete() {
117
+ if (elInterprete !== undefined) return elInterprete;
118
+ const candidatos =
119
+ process.platform === 'win32'
120
+ ? [['python'], ['py', '-3'], ['python3']]
121
+ : [['python3'], ['python']];
122
+ for (const c of candidatos) {
123
+ const r = spawnSync(c[0], [...c.slice(1), '-c', 'import sys; print(sys.version_info[0])'], {
124
+ encoding: 'utf8',
125
+ });
126
+ if (r.status === 0 && String(r.stdout).trim() === '3') {
127
+ elInterprete = c;
128
+ return elInterprete;
129
+ }
130
+ }
131
+ elInterprete = null;
132
+ return elInterprete;
133
+ }
134
+
135
+
51
136
  function ayuda() {
52
137
  const v = require(path.join(RAIZ, 'package.json')).version;
53
138
  console.log(`
@@ -60,17 +145,16 @@ function ayuda() {
60
145
  for (const [n, o] of Object.entries(ORDENES)) {
61
146
  console.log(` ${n.padEnd(11)}${o.di}`);
62
147
  }
148
+ const conShell = Object.entries(ORDENES).filter(([, o]) => o.sh).map(([n]) => n);
63
149
  console.log(`
64
- Needs python3 and tmux (the seat installs tmux itself). On Windows, run it
65
- inside WSL — the session is made of tmux windows, and there is no native tmux.
150
+ Needs Python 3, and a window server for the ones that open a city (the seat
151
+ installs it itself).
66
152
  `);
67
- }
68
-
69
- function hay(programa) {
70
- const r = spawnSync(process.platform === 'win32' ? 'where' : 'command',
71
- process.platform === 'win32' ? [programa] : ['-v', programa],
72
- { shell: process.platform !== 'win32', stdio: 'ignore' });
73
- return r.status === 0;
153
+ if (process.platform === 'win32') {
154
+ console.log(` On Windows these still need WSL, because they are shell programs:
155
+ ${conShell.join(', ')}
156
+ `);
157
+ }
74
158
  }
75
159
 
76
160
  function main() {
@@ -83,42 +167,83 @@ function main() {
83
167
  return 0;
84
168
  }
85
169
 
86
- if (process.platform === 'win32') {
87
- console.error('\n agents-city runs on tmux and bash, and Windows has neither');
88
- console.error(' natively. Install it inside WSL and run it from there:\n');
89
- console.error(' wsl -- npm install -g agents-city\n');
90
- return 1;
91
- }
92
-
93
- const orden = ORDENES[primera];
94
- const resto = orden ? args.slice(1) : args;
95
- const entrada = orden ? orden.que[0] : ORDENES.hall.que[0];
96
- if (!orden && args.length && primera.startsWith('-') === false) {
170
+ const orden = ORDENES[primera] || ORDENES.hall;
171
+ const resto = ORDENES[primera] ? args.slice(1) : args;
172
+ if (!ORDENES[primera] && args.length && primera.startsWith('-') === false) {
97
173
  console.error(`\n No such command: ${primera}\n`);
98
174
  ayuda();
99
175
  return 1;
100
176
  }
101
177
 
102
- if (!hay('python3')) {
103
- console.error('\n agents-city needs python3 on PATHthe files it manages are');
104
- console.error(' written by Python, the same modules every door shares.');
105
- console.error(' macOS: xcode-select --install · Debian: apt install python3\n');
178
+ // A door with shell logic of its own. Named, with what it is, rather than a
179
+ // blanket "this product does not run here" fifteen of these commands do.
180
+ if (orden.sh && process.platform === 'win32') {
181
+ console.error(`\n \`agents-city ${primera}\` is still a shell program, and Windows has`);
182
+ console.error(' no shell to run it in. Everything else on this install works;');
183
+ console.error(' for this one, run it inside WSL:\n');
184
+ console.error(` wsl -- agents-city ${primera}\n`);
106
185
  return 1;
107
186
  }
108
187
 
109
- const guion = path.join(RAIZ, entrada);
188
+ const objetivo = orden.sh || orden.py || orden.node;
189
+ const guion = path.join(RAIZ, objetivo);
110
190
  if (!fs.existsSync(guion)) {
111
- console.error(`\n ${entrada} is missing from this install — reinstall:`);
191
+ console.error(`\n ${objetivo} is missing from this install — reinstall:`);
112
192
  console.error(' npm install -g agents-city\n');
113
193
  return 1;
114
194
  }
195
+
196
+ let programa = guion;
197
+ let antes = [];
198
+ if (orden.py) {
199
+ const py = interprete();
200
+ if (!py) {
201
+ console.error('\n agents-city needs Python 3 on PATH — the files it manages are');
202
+ console.error(' written by Python, the same modules every door shares.');
203
+ console.error(' macOS: xcode-select --install · Debian: apt install python3');
204
+ console.error(' Windows: winget install Python.Python.3.12\n');
205
+ return 1;
206
+ }
207
+ programa = py[0];
208
+ antes = [...py.slice(1), guion];
209
+ } else if (orden.node) {
210
+ programa = process.execPath;
211
+ antes = [guion, ...(orden.antes || [])];
212
+ }
213
+
214
+ // The settings the bash doors used to establish by sourcing city-env.sh.
215
+ // Asked of the one resolver, in the one shape a non-shell can read.
216
+ // PYTHONUTF8 makes `open()` and stdout default to UTF-8 whatever code page
217
+ // the console is on. Without it, on Windows, a card with an em dash is
218
+ // written in cp1252 and read back as UTF-8 by the next door along.
219
+ let entorno = {
220
+ ...process.env, PYTHONUNBUFFERED: '1', PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8',
221
+ };
222
+ if (orden.entorno) {
223
+ const py = interprete();
224
+ if (py) {
225
+ const r = spawnSync(py[0], [...py.slice(1), path.join(RAIZ, 'plugin/scripts/city_env.py'),
226
+ '--json'], { encoding: 'utf8' });
227
+ if (r.status === 0) {
228
+ try {
229
+ entorno = { ...entorno, ...JSON.parse(r.stdout) };
230
+ } catch {
231
+ /* a resolver that cannot answer leaves the environment as it found it */
232
+ }
233
+ }
234
+ }
235
+ }
236
+
115
237
  // Unbuffered, so the URL a server prints reaches a pipe as well as a terminal
116
238
  // — a CI log or a `| head` otherwise swallows it and the run looks silent.
117
- const r = spawnSync(guion, orden ? resto : args, {
118
- stdio: 'inherit', cwd: RAIZ,
119
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
239
+ const r = spawnSync(programa, [...antes, ...resto], {
240
+ stdio: 'inherit', cwd: RAIZ, env: entorno,
120
241
  });
121
242
  return r.status === null ? 1 : r.status;
122
243
  }
123
244
 
124
- process.exit(main());
245
+ // Only when this IS the command. Exported so the checks can read the real map
246
+ // rather than a regex over this file — what each command runs, and which ones
247
+ // still need a shell, is exactly the thing that must not drift unnoticed.
248
+ if (require.main === module) process.exit(main());
249
+ module.exports = { ORDENES };
package/bin/hall CHANGED
@@ -96,7 +96,7 @@ def despega(datos, abrir):
96
96
 
97
97
  subprocess.Popen(
98
98
  [sys.executable, os.path.join(AQUI, 'serve.py'), datos],
99
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
99
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **runtime_processes.DESPEGADO,
100
100
  )
101
101
  limite = time.monotonic() + 25
102
102
  while time.monotonic() < limite:
package/bin/serve.py CHANGED
@@ -45,6 +45,7 @@ import parcels # noqa: E402
45
45
  import units # noqa: E402
46
46
  import busca # noqa: E402 the disk scanner, and the one list of where work lives
47
47
  import cities # noqa: E402
48
+ import multiplexor # noqa: E402
48
49
  import diario # noqa: E402 what happened, written down
49
50
  import demos # noqa: E402 the recorded demos the Hall plays back
50
51
  import roads # noqa: E402
@@ -277,7 +278,9 @@ def refresca_roster(ficha, datos):
277
278
  agentes = workspace.agentes(texto, datos)
278
279
  except (OSError, ValueError):
279
280
  return
280
- card.cambia_agentes(ficha, [workspace.como_ficha(a) for a in agentes])
281
+ card.cambia_agentes(
282
+ ficha, [workspace.como_ficha(a, workspace.ajustes_de(texto, a.slug)) for a in agentes]
283
+ )
281
284
 
282
285
 
283
286
  def _crecimiento_cacheado(a, datos, vida=90):
@@ -402,26 +405,13 @@ def plugin_de_verdad():
402
405
 
403
406
 
404
407
  def ventanas_vivas(owner, datos):
405
- """The window names alive in this city's tmux session, or nothing.
408
+ """The window names alive in this city's session, or nothing.
406
409
 
407
- The `=` prefix asks tmux for an exact session match: bare `-t home` also
408
- matches a session called `home-2`, and the green dot must never be lit by
409
- a different city's windows."""
410
+ The exact-match form matters: tmux's bare `-t home` also matches a session
411
+ called `home-2`, and the green dot must never be lit by a different city's
412
+ windows. Which form that is belongs to the window server, not here."""
410
413
  try:
411
- salida = subprocess.run(
412
- [
413
- "tmux",
414
- "list-windows",
415
- "-t",
416
- "=" + cities.sesion(owner, datos),
417
- "-F",
418
- "#{window_name}",
419
- ],
420
- capture_output=True,
421
- text=True,
422
- timeout=3,
423
- )
424
- return set(salida.stdout.split()) if salida.returncode == 0 else set()
414
+ return set(multiplexor.ventanas(cities.sesion(owner, datos)))
425
415
  except (OSError, subprocess.SubprocessError):
426
416
  return set()
427
417
 
@@ -524,7 +514,7 @@ def lee_clave(fichero, clave, defecto=""):
524
514
  try:
525
515
  import re as _re
526
516
 
527
- m = _re.search(rf"^{clave}:[ \t]*(.*)$", open(fichero).read(), _re.M)
517
+ m = _re.search(rf"^{clave}:[ \t]*(.*)$", open(fichero, encoding='utf-8').read(), _re.M)
528
518
  return (m.group(1).strip() if m else defecto) or defecto
529
519
  except OSError:
530
520
  return defecto
@@ -971,9 +961,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
971
961
  c.pop("texto", None)
972
962
  tarjetas.append(c)
973
963
  ps, lab, _ = parcels.lee(os.path.join(datos, "parcels.yml"))
974
- sesiones = [
975
- l.split(":")[0] for l in gh.sh(["tmux", "ls", "-F", "#{session_name}"]).splitlines()
976
- ]
964
+ sesiones = multiplexor.sesiones()
977
965
  return self.responde(
978
966
  {
979
967
  "datos": datos,
@@ -991,7 +979,19 @@ class Manejador(http.server.BaseHTTPRequestHandler):
991
979
  "parcelas": ps,
992
980
  "lab": sorted(lab),
993
981
  "gh": gh.conectado(),
994
- "tmux": sesiones,
982
+ # The window server, named on the wire rather than in the page.
983
+ # The Hall shows somebody the command to reattach to their own
984
+ # day, and that command belongs to whichever server is actually
985
+ # running the windows — so it is built from the same table the
986
+ # rest of the product drives it with, and the page prints what
987
+ # it is handed.
988
+ "mux": {
989
+ "name": multiplexor.cual(),
990
+ "sesiones": sesiones,
991
+ "attach": " ".join(
992
+ multiplexor.argv("attach", session=cities.sesion(owner, datos))
993
+ ),
994
+ },
995
995
  "plugin": plugin_de_verdad(),
996
996
  "mapa": mapa_vivo(datos),
997
997
  "sesion": cities.sesion(owner, datos),
@@ -1199,7 +1199,8 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1199
1199
  ficha_previa = os.path.join(datos, f"{quien}.md")
1200
1200
  try:
1201
1201
  texto_previo = card.lee(ficha_previa).get("texto") or ""
1202
- roster = [workspace.como_ficha(a) for a in workspace.agentes(texto_previo, datos)]
1202
+ roster = [workspace.como_ficha(a, workspace.ajustes_de(texto_previo, a.slug))
1203
+ for a in workspace.agentes(texto_previo, datos)]
1203
1204
  except (OSError, ValueError):
1204
1205
  roster = []
1205
1206
  # Absent means unchanged, present means replace. This endpoint rewrites
@@ -1444,7 +1445,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1444
1445
  [guion, datos],
1445
1446
  stdout=subprocess.DEVNULL,
1446
1447
  stderr=subprocess.DEVNULL,
1447
- start_new_session=True,
1448
+ **runtime_processes.DESPEGADO,
1448
1449
  )
1449
1450
  return self.responde({"ok": True, "mapa": ""})
1450
1451
 
@@ -1466,7 +1467,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1466
1467
  ),
1467
1468
  stdout=subprocess.DEVNULL,
1468
1469
  stderr=subprocess.DEVNULL,
1469
- start_new_session=True,
1470
+ **runtime_processes.DESPEGADO,
1470
1471
  )
1471
1472
  return self.responde(
1472
1473
  {"ok": True, "attach": f"tmux attach -t {cities.sesion(quien, datos)}"}
@@ -1543,7 +1544,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
1543
1544
  # the new one joins them. Nothing that was working stops working. The
1544
1545
  # keys come from workspace, which owns what a roster looks like on a
1545
1546
  # card — the wizard writes the very same ones after its seven questions.
1546
- roster = [workspace.como_ficha(x) for x in ya]
1547
+ roster = [workspace.como_ficha(x, workspace.ajustes_de(texto, x.slug)) for x in ya]
1547
1548
  roster.append(
1548
1549
  {"nombre": nombre, "slug": slug, "clase": clase, "rol": rol,
1549
1550
  "mounts": [], "motor": {}, "skills": []}
package/bin/setup.py CHANGED
@@ -80,7 +80,7 @@ def leeRol(ident):
80
80
  f = os.path.join(AQUI, "..", "plugin", "roles", "examples", f"{ident}.md")
81
81
  if not os.path.exists(f):
82
82
  return ""
83
- t = open(f).read()
83
+ t = open(f, encoding='utf-8').read()
84
84
  m = re.search(r"## Domain\n\n(.+?)(?:\n\n|$)", t, re.S)
85
85
  return " ".join(re.sub(r"\*\*|`", "", m.group(1)).split())[:70] if m else ""
86
86
 
@@ -573,7 +573,7 @@ def escribe(d):
573
573
  "# district instead of landing among the shared ones.",
574
574
  "lab: []",
575
575
  ]
576
- open(f"{destino}/parcels.yml", "w").write("\n".join(p) + "\n")
576
+ open(f"{destino}/parcels.yml", "w", encoding='utf-8').write("\n".join(p) + "\n")
577
577
  hechos.append(f"parcels.yml — {len(d['repos'])} repos, one house each, ready to split")
578
578
 
579
579
  ejemplos = os.path.join(AQUI, "..", "plugin", "roles", "examples")
@@ -581,7 +581,9 @@ def escribe(d):
581
581
  for r in d["roles"]:
582
582
  src = os.path.join(ejemplos, f"{r}.md")
583
583
  if os.path.exists(src):
584
- open(f"{destino}/roles/{r}.md", "w").write(open(src).read())
584
+ with open(src, encoding="utf-8") as origen:
585
+ with open(f"{destino}/roles/{r}.md", "w", encoding="utf-8") as copia:
586
+ copia.write(origen.read())
585
587
  copiados += 1
586
588
  hechos.append(f"roles/ — {copiados} role files, yours to edit")
587
589
 
@@ -636,14 +638,14 @@ def escribe(d):
636
638
  + ["_Agreed with the architect during setup._", ""]
637
639
  + cuerpo[cuerpo.index("## Round history") :]
638
640
  )
639
- open(f"{destino}/{u}.md", "w").write(
641
+ open(f"{destino}/{u}.md", "w", encoding='utf-8').write(
640
642
  "---\n" + "\n".join(fm) + "\n---\n\n" + "\n".join(cuerpo)
641
643
  )
642
644
  hechos.append(f"{len(d['gente'])} cards — one per person, goals empty on purpose")
643
645
 
644
646
  # The work domain, so the seeder and every runtime share one context.
645
647
  dominio = domains.canonico(d.get("domain") or d.get("kind") or "software")
646
- open(f"{destino}/city.yml", "w").write(
648
+ open(f"{destino}/city.yml", "w", encoding='utf-8').write(
647
649
  "# The work domain of this city. Written by the wizard; edit freely.\n"
648
650
  "#\n"
649
651
  "# `grows_with` is prose, for humans. `grow_command` is the one that matters:\n"
@@ -668,7 +670,7 @@ def escribe(d):
668
670
  if not ruta or not os.path.isdir(ruta):
669
671
  continue
670
672
  dueno = next((g["user"] for g in d["gente"] if g["role"] == "dev"), "")
671
- open(os.path.join(ruta, ".city.yml"), "w").write(
673
+ open(os.path.join(ruta, ".city.yml"), "w", encoding="utf-8").write(
672
674
  f"# This folder in the city. Read by the agent that works here.\n"
673
675
  f"parcel: {nombre}\n"
674
676
  f"unit: none # which business unit this serves\n"
@@ -677,7 +679,7 @@ def escribe(d):
677
679
  )
678
680
  agentes = os.path.join(ruta, "AGENTS.md")
679
681
  if not os.path.exists(agentes):
680
- open(agentes, "w").write(
682
+ open(agentes, "w", encoding='utf-8').write(
681
683
  f"# {nombre}\n\n"
682
684
  f"This folder is a parcel in our Agents City. What that means in practice:\n\n"
683
685
  f"- It serves one business unit. Which one is in `.city.yml`, next to this file.\n"
package/bin/test CHANGED
@@ -22,7 +22,7 @@ if [ -d .git ] && [ -x .githooks/pre-commit ] \
22
22
  && printf ' pre-commit gate armed (core.hooksPath .githooks)\n'
23
23
  fi
24
24
 
25
- SUITES=(widgets card parcels domains busca arnes i18n diario alcance serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch e2e)
25
+ SUITES=(widgets card parcels hooks multiplexor domains busca arnes i18n diario alcance serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch e2e)
26
26
  [ $# -gt 0 ] && SUITES=("$@")
27
27
 
28
28
  fallos=0
@@ -93,13 +93,16 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
93
93
  "plugin/scripts/demos.py", "plugin/scripts/arnes.py",
94
94
  "plugin/scripts/diario.py", "plugin/scripts/alcance.py",
95
95
  "plugin/scripts/consulta.py",
96
- "plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
96
+ "plugin/channel/runtime/arnes.json",
97
+ "plugin/channel/runtime/multiplexores.json",
98
+ "plugin/scripts/multiplexor.py", "plugin/scripts/capabilities.py",
97
99
  "plugin/scripts/deliberations.py",
98
100
  "plugin/scripts/runtime_processes.py",
99
101
  "plugin/scripts/runtime_log.py", "plugin/scripts/logs.py",
100
102
  "plugin/scripts/launch.py", "plugin/scripts/hook_activity.py",
101
103
  "plugin/scripts/reception.py",
102
- "plugin/scripts/city-session.sh", "plugin/scripts/city-runtime.sh",
104
+ "plugin/scripts/city-session.sh", "plugin/scripts/sesion.py",
105
+ "plugin/scripts/city-runtime.sh", "plugin/scripts/runtimes.py",
103
106
  "plugin/scripts/find-repos.sh",
104
107
  "plugin/scripts/busca.py",
105
108
  "plugin/channel/bus.js", "plugin/channel/local-hub.js",