karajan-code 4.24.0 → 4.25.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
@@ -65,7 +65,7 @@ Already installed and just want to work — even with no computing background? O
65
65
  kj go
66
66
  ```
67
67
 
68
- It detects your agent (Claude Code or Codex; asks which one only if you have both), prepares the project silently the first time, opens the board in your browser, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials.
68
+ It detects your agent (Claude Code or Codex; asks which one only if you have both), prepares the project silently the first time, opens the board in your browser, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials. Prefer everything in ONE browser window? `kj go --window` embeds the agent's real terminal inside the board (loopback-only, single-session token).
69
69
 
70
70
  Requires git and at least one AI agent CLI — two enables cross-AI review; three enables arbitration. The npm route is `npm install -g @karajan-family/code` (published as `karajan-code` before joining the scope; the legacy name still installs the same versions). All install routes (npm, binaries, brew, Python wrapper) in the [install docs](https://karajancode.com/docs/v4/install/).
71
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.24.0",
3
+ "version": "4.25.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -13,13 +13,16 @@
13
13
  "test": "vitest run"
14
14
  },
15
15
  "dependencies": {
16
- "karajan-core": "*",
16
+ "@xterm/xterm": "^6.0.0",
17
17
  "better-sqlite3": "^12.10.0",
18
18
  "chokidar": "^5.0.0",
19
19
  "express": "^5.1.0",
20
20
  "express-rate-limit": "^8.5.0",
21
21
  "helmet": "^8.1.0",
22
- "js-yaml": "^4.2.0"
22
+ "js-yaml": "^4.2.0",
23
+ "karajan-core": "*",
24
+ "node-pty": "^1.1.0",
25
+ "ws": "^8.21.3"
23
26
  },
24
27
  "devDependencies": {
25
28
  "supertest": "^7.1.0",
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Karajan HU Board</title>
7
7
  <link rel="stylesheet" href="/styles.css">
8
+ <link rel="stylesheet" href="/vendor/xterm/css/xterm.css">
8
9
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%237c3aed'/><text x='16' y='22' text-anchor='middle' fill='white' font-size='16' font-family='sans-serif' font-weight='bold'>K</text></svg>">
9
10
  </head>
10
11
  <body>
@@ -35,6 +36,7 @@
35
36
  <button class="control-btn" id="help-btn" title="What does each view do? Quick reference for Board / Graph / Dashboard / Sessions / Pipeline.">?</button>
36
37
  <button class="control-btn" id="config-btn" title="Configurar Karajan (modelos, agentes, tope de iteraciones, …)">⚙</button>
37
38
  <button class="control-btn" id="commands-btn" title="Launch a Karajan CLI command (kj plan, kj architect, kj clean…)">⚡</button>
39
+ <button class="control-btn" id="conversation-btn" title="Conversación con tu agente — la terminal real, dentro del board (ADR 0008)" onclick="openConversationPanel()">💬</button>
38
40
  <button class="control-btn" id="restart-btn" title="Restart the HU Board server (preserves URL, the page reconnects automatically)">🔁</button>
39
41
  <button class="control-btn" id="sync-btn" title="Re-scan disk for new batches">🔄</button>
40
42
  <button class="control-btn" id="force-refresh-btn" title="Force a clean reload — wipes browser caches and reloads. Use when the UI looks stale and a normal refresh isn't fixing it." onclick="window.forceRefresh()">🧹</button>
@@ -67,6 +69,8 @@
67
69
  <script src="/utils/preflight-view.js"></script>
68
70
  <script src="/utils/plan-rollup-view.js"></script>
69
71
  <script src="/utils/log-panel.js"></script>
72
+ <script src="/vendor/xterm/lib/xterm.js"></script>
73
+ <script src="/utils/terminal-panel.js"></script>
70
74
  <script src="/utils/hu-actions.js"></script>
71
75
  <script src="/utils/project-actions.js"></script>
72
76
  <script src="/utils/story-edit-form.js"></script>
@@ -39,6 +39,9 @@ const MAGGLE_LABELS = {
39
39
  'launcher.cancel': 'Cancelar',
40
40
  'launcher.more': 'Más opciones…',
41
41
  'launcher.confirm': 'Voy a convertir tu petición en tareas del tablero. Tardará unos minutos y podrás seguir la actividad en esta ventana. ¿Sigo?',
42
+ 'conversation.title': 'Habla con tu agente',
43
+ 'conversation.hide': 'Ocultar — la conversación sigue viva',
44
+ 'conversation.end': 'Terminar la sesión del agente',
42
45
  'log.label': 'Actividad',
43
46
  'log.connecting': 'conectando…',
44
47
  'log.footer': 'Cerrar esta ventana NO detiene el trabajo: sigue en marcha. Vuelve a abrirla con el botón «📜 Ver actividad» del tablero.',
@@ -0,0 +1,132 @@
1
+ // KJC-TSK-0816 (MGL-E, ADR 0008) — el panel «Conversación»: la terminal
2
+ // REAL del agente dentro del board. Script clásico, como el resto.
3
+ //
4
+ // Globals: Terminal (de /vendor/xterm), esc(), maggleText(), showError().
5
+ // El token del pty llega de /api/terminal/start (tras el auth del board)
6
+ // y viaja al WS como SUBPROTOCOLO, nunca en la URL.
7
+
8
+ let conversationState = { ws: null, term: null, panel: null, termId: null, token: null, opening: false };
9
+
10
+ async function openConversationPanel() {
11
+ if (conversationState.panel && conversationState.dead) {
12
+ // La conexión murió (agente terminado o red): reabrir = sesión fresca.
13
+ conversationState.panel.remove();
14
+ conversationState = { ws: null, term: null, panel: null, termId: null, token: null, opening: false };
15
+ }
16
+ if (conversationState.panel) {
17
+ conversationState.panel.style.display = 'flex';
18
+ conversationState.term?.focus();
19
+ return;
20
+ }
21
+ // Un doble clic (o ?window=1 + clic) no debe abrir dos sesiones.
22
+ if (conversationState.opening) return;
23
+ conversationState.opening = true;
24
+ let started;
25
+ try {
26
+ const res = await fetch('/api/terminal/start', {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ // Sin agente en el body: decide el servidor (kj go --window fija el
30
+ // elegido por env; sin él, claude).
31
+ body: JSON.stringify({}),
32
+ });
33
+ started = await res.json();
34
+ if (!res.ok) throw new Error(started.error || `HTTP ${res.status}`);
35
+ } catch (err) {
36
+ conversationState.opening = false;
37
+ await showError(err.message, { title: 'La conversación no pudo arrancar' });
38
+ return;
39
+ }
40
+ conversationState.opening = false;
41
+ conversationState.termId = started.termId;
42
+ conversationState.token = started.token;
43
+
44
+ const panel = document.createElement('section');
45
+ panel.id = 'kj-conversation-panel';
46
+ panel.style.cssText = [
47
+ 'position:fixed', 'top:0', 'right:0', 'bottom:0',
48
+ 'width:min(720px, 55vw)', 'display:flex', 'flex-direction:column',
49
+ 'background:#0b0b0c', 'border-left:1px solid var(--border)',
50
+ 'box-shadow:-12px 0 40px rgba(0,0,0,0.45)', 'z-index:9998',
51
+ ].join(';');
52
+ panel.innerHTML = `
53
+ <header style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;
54
+ background:var(--bg-primary);border-bottom:1px solid var(--border);flex-shrink:0">
55
+ <strong style="color:var(--text)">${esc(maggleText('conversation.title', 'Conversación con tu agente'))}</strong>
56
+ <div style="display:flex;gap:6px">
57
+ <button id="conv-hide" type="button" class="control-btn" title="${esc(maggleText('conversation.hide', 'Ocultar — la conversación sigue viva'))}"
58
+ style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">_</button>
59
+ <button id="conv-end" type="button" class="control-btn" title="${esc(maggleText('conversation.end', 'Terminar la sesión del agente'))}"
60
+ style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-primary);color:#f87171;border-radius:var(--radius-sm);cursor:pointer">✕</button>
61
+ </div>
62
+ </header>
63
+ <div id="conv-term" style="flex:1 1 auto;min-height:0;padding:6px"></div>
64
+ `;
65
+ document.body.appendChild(panel);
66
+ conversationState.panel = panel;
67
+
68
+ const term = new Terminal({
69
+ convertEol: false,
70
+ cursorBlink: true,
71
+ fontFamily: 'JetBrains Mono, monospace',
72
+ fontSize: 13,
73
+ theme: { background: '#0b0b0c' },
74
+ });
75
+ term.open(panel.querySelector('#conv-term'));
76
+ conversationState.term = term;
77
+
78
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
79
+ const ws = new WebSocket(
80
+ `${proto}://${location.host}/api/terminal/ws?termId=${encodeURIComponent(started.termId)}`,
81
+ ['kj-terminal', started.token],
82
+ );
83
+ conversationState.ws = ws;
84
+ ws.onmessage = (e) => term.write(typeof e.data === 'string' ? e.data : '');
85
+ ws.onclose = () => {
86
+ conversationState.dead = true;
87
+ term.write('\r\n[conexión cerrada — vuelve a abrir el panel para reconectar]\r\n');
88
+ };
89
+ term.onData((data) => {
90
+ if (ws.readyState === WebSocket.OPEN) ws.send(data);
91
+ });
92
+
93
+ const sendResize = () => {
94
+ const el = panel.querySelector('#conv-term');
95
+ const cols = Math.max(40, Math.floor(el.clientWidth / 8));
96
+ const rows = Math.max(10, Math.floor(el.clientHeight / 18));
97
+ term.resize(cols, rows);
98
+ if (ws.readyState === WebSocket.OPEN) {
99
+ ws.send(`\u0000${JSON.stringify({ type: 'resize', cols, rows })}`);
100
+ }
101
+ };
102
+ ws.onopen = () => { sendResize(); term.focus(); };
103
+ window.addEventListener('resize', sendResize);
104
+
105
+ panel.querySelector('#conv-hide').addEventListener('click', () => {
106
+ panel.style.display = 'none';
107
+ });
108
+ panel.querySelector('#conv-end').addEventListener('click', async () => {
109
+ try {
110
+ await fetch('/api/terminal/stop', {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ termId: conversationState.termId, token: conversationState.token }),
114
+ });
115
+ } finally {
116
+ ws.close();
117
+ panel.remove();
118
+ conversationState = { ws: null, term: null, panel: null, termId: null, token: null, opening: false };
119
+ }
120
+ });
121
+ }
122
+
123
+ // kj go --window abre el board con ?window=1: la conversación arranca sola.
124
+ if (typeof document !== 'undefined' && document.addEventListener) {
125
+ document.addEventListener('DOMContentLoaded', () => {
126
+ try {
127
+ if (new URLSearchParams(location.search).get('window') === '1') openConversationPanel();
128
+ } catch {
129
+ // Sin soporte de URLSearchParams no hay auto-apertura; el botón queda.
130
+ }
131
+ });
132
+ }
@@ -13,6 +13,9 @@ import wikiRoutes from './routes/wiki.js';
13
13
  import governanceRoutes from './routes/governance.js';
14
14
  import { authMiddleware } from './auth.js';
15
15
  import { getOrCreateToken, getTokenPath } from './token-store.js';
16
+ import { createRequire } from 'node:module';
17
+ import { createTerminalManager } from './terminal.js';
18
+ import { terminalRouter, attachTerminalWs } from './terminal-wire.js';
16
19
  import { reapZombieSessions } from './zombie-reaper.js';
17
20
  import { reapZombieHus } from './hu-zombie-reaper.js';
18
21
  import { setHuStatus as setHuStatusPlanMutation, setHuFailResult as setHuFailResultPlanMutation } from './plan-mutations.js';
@@ -295,6 +298,8 @@ async function main() {
295
298
 
296
299
  // Create Express app
297
300
  const app = express();
301
+ // node-pty es CJS nativo y @xterm se localiza por resolve: require clásico.
302
+ const requireCjs = createRequire(import.meta.url);
298
303
  app.use(...buildSecurityMiddleware());
299
304
  app.use(express.json());
300
305
  app.use(express.static(PUBLIC_DIR, { setHeaders: noStoreHeaders, etag: false, lastModified: false }));
@@ -304,6 +309,25 @@ async function main() {
304
309
  app.use('/api/wiki', authMiddleware(), wikiRoutes);
305
310
  app.use('/api/governance', authMiddleware(), governanceRoutes);
306
311
 
312
+ // Terminal embebida (KJC-TSK-0816, ADR 0008): la lógica vive en
313
+ // terminal.js; aquí solo el montaje. El agente arranca en el cwd del
314
+ // daemon (kj go arranca el board desde el proyecto) u override por env.
315
+ // xterm se sirve DESDE la dependencia — nada vendorizado al repo.
316
+ const terminalManager = createTerminalManager({
317
+ spawnPty: (...args) => requireCjs('node-pty').spawn(...args),
318
+ cwd: process.env.HU_BOARD_TERMINAL_CWD || process.cwd(),
319
+ promptArg: process.env.HU_BOARD_TERMINAL_PROMPT,
320
+ });
321
+ app.use(
322
+ '/api/terminal',
323
+ authMiddleware(),
324
+ terminalRouter(terminalManager, { defaultAgent: process.env.HU_BOARD_TERMINAL_AGENT || 'claude' }),
325
+ );
326
+ app.use(
327
+ '/vendor/xterm',
328
+ express.static(dirname(requireCjs.resolve('@xterm/xterm/package.json'))),
329
+ );
330
+
307
331
  // SPA fallback: serve index.html for non-API, non-static routes
308
332
  app.get('/{*splat}', (_req, res) => {
309
333
  res.setHeader('Cache-Control', 'no-store, must-revalidate');
@@ -318,6 +342,7 @@ async function main() {
318
342
  const isLoopback = LOOPBACK_ADDRESSES.has(bindHost);
319
343
 
320
344
  const server = app.listen(port, bindHost, () => {
345
+ attachTerminalWs(server, terminalManager);
321
346
  // Write the PID file so the CLI's `kj board status / stop` and
322
347
  // the next `kj plan`'s startBoard() check find this server. The
323
348
  // file used to be written only by `kj board start`'s launcher,
@@ -1,6 +1,7 @@
1
1
  import { watch } from 'chokidar';
2
+ import { execFileSync } from 'node:child_process';
2
3
  import { readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
3
- import { dirname, join, basename } from 'node:path';
4
+ import { dirname, join, basename, isAbsolute, resolve as resolvePath } from 'node:path';
4
5
  import { homedir } from 'node:os';
5
6
  import {
6
7
  getKjHome,
@@ -93,7 +94,7 @@ function deriveProjectName(data, fallbackId) {
93
94
  * @param {string} projectDir
94
95
  * @returns {string}
95
96
  */
96
- function deriveProjectIdFromDir(projectDir) {
97
+ export function deriveProjectIdFromDir(projectDir) {
97
98
  if (!projectDir || typeof projectDir !== 'string') return 'unknown';
98
99
  // Same slug rule the plan-store uses, so identifiers match if we ever
99
100
  // cross-reference `~/.kj/plans/<slug>/` with this project_id.
@@ -104,6 +105,42 @@ function deriveProjectIdFromDir(projectDir) {
104
105
  .slice(0, 120);
105
106
  }
106
107
 
108
+ /** git-common-dir del directorio, con el stderr silenciado (no-git = throw). */
109
+ const defaultGitCommonDir = (dir) =>
110
+ execFileSync('git', ['-C', dir, 'rev-parse', '--git-common-dir'], {
111
+ encoding: 'utf8',
112
+ stdio: ['ignore', 'pipe', 'ignore'],
113
+ }).trim();
114
+
115
+ const canonicalDirCache = new Map();
116
+
117
+ /**
118
+ * Resolve a projectDir to the REPO's identity (KJC-BUG-0160): a linked
119
+ * worktree (`.claude/worktrees/*`, kj lanes) used to register the same
120
+ * repo as a brand-new project — 13+ same-named entries in a real picker.
121
+ * `git rev-parse --git-common-dir` points every worktree at the main
122
+ * tree's `.git`; its parent is the canonical root. A non-git directory
123
+ * keeps its own path as identity, as always.
124
+ *
125
+ * @param {string} projectDir
126
+ * @param {(dir: string) => string} [gitCommonDir] Inyectable en tests.
127
+ * @returns {string}
128
+ */
129
+ export function canonicalProjectDir(projectDir, gitCommonDir = defaultGitCommonDir) {
130
+ if (!projectDir || typeof projectDir !== 'string') return projectDir;
131
+ if (canonicalDirCache.has(projectDir)) return canonicalDirCache.get(projectDir);
132
+ let canonical = projectDir;
133
+ try {
134
+ const common = gitCommonDir(projectDir);
135
+ const absCommon = isAbsolute(common) ? common : resolvePath(projectDir, common);
136
+ canonical = dirname(absCommon);
137
+ } catch {
138
+ // Sin git no hay más identidad que la ruta — comportamiento de siempre.
139
+ }
140
+ canonicalDirCache.set(projectDir, canonical);
141
+ return canonical;
142
+ }
143
+
107
144
  /**
108
145
  * Turn a directory basename into a human-friendly Title Case string,
109
146
  * preserving every meaningful word. Unlike `slugToTitle`, this one does
@@ -379,9 +416,10 @@ export function syncPlanFile(filePath) {
379
416
  // under a single board entry. Legacy plans without `projectDir` still
380
417
  // fall back to `planId` (conservative — they at least continue to show
381
418
  // up, just each on its own card).
382
- const projectId = data.projectDir
383
- ? deriveProjectIdFromDir(data.projectDir)
384
- : data.planId;
419
+ // KJC-BUG-0160: la identidad es el ROOT del repo — un plan creado desde
420
+ // un worktree/carril agrupa bajo el mismo proyecto que el árbol principal.
421
+ const canonicalDir = data.projectDir ? canonicalProjectDir(data.projectDir) : null;
422
+ const projectId = canonicalDir ? deriveProjectIdFromDir(canonicalDir) : data.planId;
385
423
 
386
424
  if (skipIfTombstoned('plan', data.planId, filePath)) return;
387
425
  // KJC-BUG-0055 (supersedes KJC-BUG-0050): gating por timestamp.
@@ -419,7 +457,7 @@ export function syncPlanFile(filePath) {
419
457
  // misleadingly say "Hu Board" — that's why plan.name wins.
420
458
  // 3. slugToTitle of the task / projectId as a last resort.
421
459
  const projectName = data.name
422
- || (data.projectDir ? titleCaseBasename(basename(data.projectDir)) : null)
460
+ || (canonicalDir ? titleCaseBasename(basename(canonicalDir)) : null)
423
461
  || slugToTitle(data.task || '')
424
462
  || projectId;
425
463
 
@@ -0,0 +1,96 @@
1
+ // KJC-TSK-0816 — pegamento fino de la terminal embebida: rutas REST para
2
+ // arrancar/parar la sesión y el upgrade WebSocket que puentea el pty.
3
+ // La LÓGICA (tokens, catálogo, ciclo de vida) vive en terminal.js y está
4
+ // testeada con dobles; esto es cableado deliberadamente delgado.
5
+ //
6
+ // El token NO viaja en la URL: el cliente lo manda como subprotocolo
7
+ // WebSocket (['kj-terminal', token]) — las URLs acaban en logs; los
8
+ // subprotocolos, no.
9
+ import { Router } from 'express';
10
+ import { WebSocket, WebSocketServer } from 'ws';
11
+
12
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
13
+ const WS_PATH = '/api/terminal/ws';
14
+
15
+ /**
16
+ * Monta las rutas REST de la terminal sobre un Router de express.
17
+ * @param {ReturnType<import('./terminal.js').createTerminalManager>} manager
18
+ */
19
+ export function terminalRouter(manager, { defaultAgent = 'claude' } = {}) {
20
+ const router = Router();
21
+ router.post('/start', (req, res) => {
22
+ try {
23
+ res.json(manager.start({ agent: req.body?.agent ?? defaultAgent }));
24
+ } catch (err) {
25
+ res.status(400).json({ error: err.message });
26
+ }
27
+ });
28
+ router.get('/alive', (_req, res) => res.json({ alive: manager.alive() }));
29
+ router.post('/stop', (req, res) => {
30
+ try {
31
+ manager.stop({ termId: req.body?.termId, token: req.body?.token });
32
+ res.json({ stopped: true });
33
+ } catch (err) {
34
+ res.status(400).json({ error: err.message });
35
+ }
36
+ });
37
+ return router;
38
+ }
39
+
40
+ /**
41
+ * Registra el upgrade WS en el servidor http. Entrada del cliente: bytes
42
+ * crudos = teclado; un mensaje que empiece por \x00 es control JSON
43
+ * ({type:'resize', cols, rows}) — el prefijo evita confundir un '{'
44
+ * tecleado con un mensaje de control.
45
+ *
46
+ * @param {import('node:http').Server} server
47
+ * @param {ReturnType<import('./terminal.js').createTerminalManager>} manager
48
+ */
49
+ export function attachTerminalWs(server, manager) {
50
+ const wss = new WebSocketServer({ noServer: true });
51
+ server.on('upgrade', (req, socket, head) => {
52
+ const url = new URL(req.url, 'http://localhost');
53
+ if (url.pathname !== WS_PATH) return;
54
+ if (!LOOPBACK.has(req.socket.remoteAddress)) {
55
+ socket.destroy();
56
+ return;
57
+ }
58
+ const termId = url.searchParams.get('termId');
59
+ const protocols = String(req.headers['sec-websocket-protocol'] ?? '')
60
+ .split(',')
61
+ .map((p) => p.trim());
62
+ const token = protocols[1] ?? '';
63
+ wss.handleUpgrade(req, socket, head, (ws) => {
64
+ let detach;
65
+ try {
66
+ detach = manager.attach({
67
+ termId,
68
+ token,
69
+ send: (data) => {
70
+ if (ws.readyState === WebSocket.OPEN) ws.send(data);
71
+ },
72
+ });
73
+ } catch (err) {
74
+ ws.close(1008, err.message);
75
+ return;
76
+ }
77
+ ws.on('message', (raw) => {
78
+ const text = raw.toString('utf8');
79
+ try {
80
+ if (text.charCodeAt(0) === 0) {
81
+ const msg = JSON.parse(text.slice(1));
82
+ if (msg.type === 'resize') {
83
+ manager.resize({ termId, token, cols: msg.cols, rows: msg.rows });
84
+ }
85
+ return;
86
+ }
87
+ manager.write({ termId, token, data: text });
88
+ } catch {
89
+ // Una trama malformada no tumba la sesión: se ignora y se sigue.
90
+ }
91
+ });
92
+ ws.on('close', () => detach());
93
+ });
94
+ });
95
+ return wss;
96
+ }
@@ -0,0 +1,122 @@
1
+ // KJC-TSK-0816 (MGL-E, ADR 0008) — la ventana única: la terminal REAL del
2
+ // agente embebida en el board; el proceso es el agente interactivo de
3
+ // siempre, con los hooks del Sentinel intactos. Seguridad de la superficie:
4
+ // server solo en loopback, token aleatorio de 128 bits entregado tras el
5
+ // auth del board y comparado en tiempo constante, y catálogo de agentes
6
+ // CERRADO — por la red viaja qué agente, jamás un comando arbitrario.
7
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
8
+
9
+ /** Catálogo cerrado: los mismos agentes que kj go sabe lanzar. */
10
+ export const TERMINAL_AGENTS = Object.freeze({
11
+ claude: { command: 'claude', args: [] },
12
+ codex: { command: 'codex', args: [] },
13
+ });
14
+
15
+ const tokenMatches = (expected, given) => {
16
+ const a = Buffer.from(String(expected));
17
+ const b = Buffer.from(String(given ?? ''));
18
+ return a.length === b.length && timingSafeEqual(a, b);
19
+ };
20
+
21
+ /**
22
+ * Gestor de LA sesión de terminal del board (singleton por diseño: una
23
+ * conversación por board acota la superficie; multi-terminal queda fuera
24
+ * del alcance del ADR).
25
+ *
26
+ * @param {Object} deps
27
+ * @param {(command: string, args: string[], opts: object) => object} deps.spawnPty
28
+ * Factoría del pty (node-pty.spawn en producción; fake en tests).
29
+ * @param {string} deps.cwd Directorio del proyecto donde vive el agente.
30
+ * @param {Record<string, string|undefined>} [deps.env]
31
+ * @param {string} [deps.promptArg] Prompt inicial del agente (kj go --window
32
+ * lo pasa por env al daemon) — SIEMPRE del lado servidor, jamás de la red.
33
+ */
34
+ export function createTerminalManager({ spawnPty, cwd, env = process.env, promptArg }) {
35
+ /** @type {{termId: string, token: string, pty: object, subscribers: Set<Function>, buffer: string[]} | null} */
36
+ let session = null;
37
+
38
+ const requireSession = (termId, token) => {
39
+ if (!session || session.termId !== termId) {
40
+ throw new Error('terminal: no hay sesión viva con ese id.');
41
+ }
42
+ if (!tokenMatches(session.token, token)) {
43
+ throw new Error('terminal: token inválido.');
44
+ }
45
+ return session;
46
+ };
47
+
48
+ return {
49
+ /** Arranca (o devuelve) LA sesión — reutilizar la viva evita que cada
50
+ * recarga multiplique agentes. @param {{agent: string}} params */
51
+ start({ agent }) {
52
+ if (session) return { termId: session.termId, token: session.token, reused: true };
53
+ // hasOwn: sin él, "constructor"/"__proto__" indexarían el prototipo.
54
+ const spec = Object.hasOwn(TERMINAL_AGENTS, agent) ? TERMINAL_AGENTS[agent] : null;
55
+ if (!spec) {
56
+ throw new Error(
57
+ `terminal: agente "${agent}" fuera del catálogo (${Object.keys(TERMINAL_AGENTS).join(', ')}).`,
58
+ );
59
+ }
60
+ // CLAUDECODE fuera: un Claude anidado se niega a arrancar con ella
61
+ // (la misma peculiaridad que ya maneja kj go).
62
+ const { CLAUDECODE: _omit, ...cleanEnv } = env;
63
+ const args = promptArg ? [...spec.args, promptArg] : spec.args;
64
+ const pty = spawnPty(spec.command, args, {
65
+ name: 'xterm-256color',
66
+ cols: 120,
67
+ rows: 32,
68
+ cwd,
69
+ env: cleanEnv,
70
+ });
71
+ const current = {
72
+ termId: `term-${randomBytes(8).toString('hex')}`,
73
+ token: randomBytes(16).toString('hex'),
74
+ pty,
75
+ subscribers: new Set(),
76
+ buffer: [],
77
+ };
78
+ pty.onData((data) => {
79
+ // Búfer corto para que una reconexión no arranque en negro.
80
+ current.buffer.push(data);
81
+ if (current.buffer.length > 200) current.buffer.shift();
82
+ for (const send of current.subscribers) send(data);
83
+ });
84
+ pty.onExit(() => {
85
+ for (const send of current.subscribers) send('\r\n[la sesión del agente terminó]\r\n');
86
+ // El exit tardío de un pty viejo no debe anular una sesión nueva.
87
+ if (session === current) session = null;
88
+ });
89
+ session = current;
90
+ return { termId: current.termId, token: current.token, reused: false };
91
+ },
92
+
93
+ /**
94
+ * Conecta un receptor de salida. Devuelve el detach; soltar la
95
+ * conexión NO mata el pty — el agente sigue y se puede volver.
96
+ */
97
+ attach({ termId, token, send }) {
98
+ const s = requireSession(termId, token);
99
+ for (const chunk of s.buffer) send(chunk);
100
+ s.subscribers.add(send);
101
+ return () => s.subscribers.delete(send);
102
+ },
103
+
104
+ write({ termId, token, data }) {
105
+ requireSession(termId, token).pty.write(data);
106
+ },
107
+
108
+ resize({ termId, token, cols, rows }) {
109
+ requireSession(termId, token).pty.resize(cols, rows);
110
+ },
111
+
112
+ stop({ termId, token }) {
113
+ const s = requireSession(termId, token);
114
+ s.pty.kill();
115
+ session = null;
116
+ },
117
+
118
+ alive() {
119
+ return session ? { termId: session.termId } : null;
120
+ },
121
+ };
122
+ }
@@ -154,6 +154,7 @@ export function registerMeta(program, { pkgVersion }) {
154
154
  program
155
155
  .command("go")
156
156
  .description("Arranca Karajan sin saber nada: detecta tu agente, prepara el proyecto, abre el tablero y te deja en la conversación")
157
+ .option("--window", "La ventana única (ADR 0008): la conversación vive DENTRO del tablero del navegador")
157
158
  .action(async (flags) => {
158
159
  await withConfig(pkgVersion, "go", flags, async ({ config, logger }) => {
159
160
  await goCommand({ config, logger, flags });
@@ -46,12 +46,13 @@ export function buildGoPrompt() {
46
46
  async function defaultPrepare({ config, logger }) {
47
47
  await envInstallCommand({ config, logger, flags: { yes: true } });
48
48
  }
49
- export async function defaultBoard({ config, logger, runBoard = boardCommand }) {
49
+ export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
50
50
  const port = config.hu_board?.port || 4000;
51
51
  await runBoard({ action: "start", port, bind: "127.0.0.1", logger });
52
52
  // /?maggle=1 switches the frontend to plain language (KJC-TSK-0810) —
53
- // the muggle's window opens already speaking their language.
54
- await runBoard({ action: "open", port, bind: "127.0.0.1", path: "/?maggle=1", logger });
53
+ // the muggle's window opens already speaking their language. With
54
+ // &window=1 (KJC-TSK-0816) the conversation itself lives IN the board.
55
+ await runBoard({ action: "open", port, bind: "127.0.0.1", path: openPath, logger });
55
56
  }
56
57
  function defaultLaunch(agent, prompt) {
57
58
  // Interactive session: the muggle LIVES here. CLAUDECODE is stripped so a
@@ -96,13 +97,41 @@ export async function goCommand({ config = {}, logger = console, flags = {}, dep
96
97
  logger.info?.("Preparando tu proyecto (solo la primera vez)…");
97
98
  await (deps.prepare ?? defaultPrepare)({ config, logger });
98
99
  }
100
+ const prompt = (deps.prompt ?? buildGoPrompt)();
101
+ // --window (MGL-E, ADR 0008): la conversación vive DENTRO del board — el
102
+ // daemon hereda por env (HU_BOARD_TERMINAL_CWD/AGENT/PROMPT) el cwd, el
103
+ // agente elegido y el prompt inicial, y el pty arranca el agente REAL
104
+ // (harness intacto). La fase 1 sigue siendo el default: sin el flag,
105
+ // nada cambia.
106
+ const windowMode = Boolean(flags.window);
107
+ if (windowMode) {
108
+ process.env.HU_BOARD_TERMINAL_CWD = projectDir;
109
+ process.env.HU_BOARD_TERMINAL_AGENT = chosen.name;
110
+ process.env.HU_BOARD_TERMINAL_PROMPT = prompt;
111
+ }
99
112
  // The board is the muggle's window — unless the project turned it off
100
113
  // (hu_board.enabled false is respected: KJC-BUG-0152). A board failure is
101
114
  // said and never stops the conversation from starting.
115
+ let boardOpened = false;
102
116
  if (config.hu_board?.enabled !== false) {
103
- try { await (deps.board ?? defaultBoard)({ config, logger }); } catch (err) { logger.warn?.(`El tablero no pudo abrirse (${err.message}) — la conversación arranca igual.`); }
117
+ const openPath = windowMode ? "/?maggle=1&window=1" : "/?maggle=1";
118
+ try {
119
+ await (deps.board ?? defaultBoard)({ config, logger, openPath });
120
+ boardOpened = true;
121
+ } catch (err) {
122
+ logger.warn?.(`El tablero no pudo abrirse (${err.message}) — la conversación arranca igual.`);
123
+ }
124
+ }
125
+ if (windowMode && boardOpened) {
126
+ logger.info?.("Tu conversación vive en la ventana del navegador que se acaba de abrir. Si el tablero ya estaba arrancado de antes, reinícialo con kj board stop && kj go --window para que recoja este proyecto.");
127
+ process.exitCode = 0;
128
+ return 0;
129
+ }
130
+ if (windowMode) {
131
+ // Sin board no hay ventana: se dice y la conversación arranca en la
132
+ // terminal — el maggle nunca se queda sin sesión (catch de codex).
133
+ logger.warn?.("Sin tablero no hay ventana única: abro la conversación aquí mismo.");
104
134
  }
105
- const prompt = (deps.prompt ?? buildGoPrompt)();
106
135
  logger.info?.("Abriendo tu conversación… (escribe ahí lo que necesites, en tu idioma)");
107
136
  const code = await (deps.launch ?? defaultLaunch)(chosen, prompt);
108
137
  process.exitCode = code;