@luanpdd/kit-mcp 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,106 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.0] - 2026-05-04
10
+
11
+ **GUI sidecar de acompanhamento.** Janela web localhost paralela mostra ao vivo (via Server-Sent Events) o que kit-mcp está fazendo enquanto sua IDE chama tools — `sync install`, `reverse-sync apply`, `gates run`. Sidecar é totalmente opt-in: quem não invoca `kit ui` continua com a experiência v1.1 idêntica.
12
+
13
+ ### Adicionado — Phase 11: Lock arquitetural
14
+ - ADR consolidado em `.planning/decisions.md` (porta 7100-7199, lockfile em `os.tmpdir()` keyed por sha1(projectRoot), idle 30min default, sem auth no v1.2 com mitigação compensatória)
15
+ - Threat model em `docs/sidecar-security.md`
16
+ - 2 audit gates novos no CI: stdout discipline em `src/ui/` (proíbe `console.log`/`process.stdout.write`) e dep budget (≤ baseline+1)
17
+
18
+ ### Adicionado — Phase 12: Fundações
19
+ - `src/ui/events.js` — schema de evento, validador puro, `makeEvent`, `newRunId`
20
+ - `src/ui/port.js` — `findFreePort` na faixa 7100-7199 com retry-loop
21
+ - `src/ui/lockfile.js` — `acquireLock` atômico via `O_EXCL`, `probeStale` via `process.kill(pid, 0)` + healthz HTTP
22
+
23
+ ### Adicionado — Phase 13: Servidor HTTP + SSE
24
+ - `src/ui/server.js` — http.Server nativo, bind 127.0.0.1 literal, 5 rotas (`/`, `/events` SSE, `/healthz`, `/state`, `/publish`, `/shutdown`)
25
+ - Heartbeat `: ping\n\n` cada 15s; reconnect auto via EventSource native + `retry: 3000`
26
+ - Ring buffer in-memory de 200 eventos (FIFO; sem persistência em disco)
27
+ - Cap de 32 conexões SSE; cleanup quádruplo (req+res × close+error)
28
+ - Idle shutdown 30min default (`--idle-ms 0` desabilita)
29
+ - Encerramento gracioso em SIGINT/SIGTERM com active sockets destruídos
30
+ - Validação de `Host` header (mitiga DNS rebinding) e `Origin` em endpoints non-GET
31
+ - `bin/ui.js` entry detached
32
+
33
+ ### Adicionado — Phase 14: UI estática single-file
34
+ - `src/ui/static/index.html` (~470 LOC) — vanilla DOM + EventSource, sem build step
35
+ - Lista cronológica + auto-scroll + `<details>` expand
36
+ - Badges coloridos por tipo (`run.start`, `run.end`, `tool_invocation`, `progress`, `milestone`, `error`, `shutdown`)
37
+ - Status conexão (CONNECTING/OPEN/CLOSED) + reconexão automática
38
+ - Filter por tipo (chips) + substring search
39
+ - Pause/resume com buffer + autoscroll toggle
40
+ - Dark mode automático via `prefers-color-scheme`
41
+ - Banner de shutdown PT-BR em CLOSED >5s ou evento `shutdown`
42
+ - CSP estrito (`default-src 'self'; ...; frame-ancestors 'none'`)
43
+
44
+ ### Adicionado — Phase 15: Publisher + wrapper + browser-open
45
+ - `src/ui/client.js` — `publish(event, {projectRoot})` fire-and-forget, cache TTL 5s, falha silenciosa em ECONNREFUSED
46
+ - `src/ui/wrapper.js` — `wrapProgressForUi(onProgress, ctx)` multiplexa terminal + sidecar; helpers `.done/.error/.emit`; `redactPath` central scrubando `$HOME → ~` e `projectRoot → <project>` em TODO payload
47
+ - `src/ui/browser.js` — wrapper sobre `open@11` com detection de headless (CI, DISPLAY, SSH, WSL, sandbox); fallback "imprime URL no stderr"
48
+ - Nova dep: `open@^11.0.0` (única adição; budget atingido em 6/6)
49
+
50
+ ### Adicionado — Phase 16: CLI integration
51
+ - `kit ui start` — sobe sidecar foreground (Ctrl+C mata); flags `--port`, `--idle-ms`, `--no-open`
52
+ - `kit ui stop` — POST /shutdown
53
+ - `kit ui status` — exibe pid, port, uptime, eventos, subscribers
54
+ - `kit ui open` — reabre browser na sidecar atual
55
+ - Auto-detect: `kit sync install` e `kit reverse-sync apply` checam lockfile e wrappam `onProgress` automaticamente quando sidecar está rodando
56
+ - Opt-out global via `--no-ui` flag ou `KIT_MCP_NO_UI=1` env var
57
+
58
+ ### Adicionado — Phase 17: MCP --auto-spawn
59
+ - `src/ui/auto-spawn.js` — `ensureSidecar({projectRoot})` checa lockfile + healthz; se ausente, spawna `bin/ui.js` em **detached** com `windowsHide: true` e `stdio: ['ignore', 'ignore', 'inherit']` (fecha stdout completamente — não pode poluir canal MCP do parent)
60
+ - 3 tools MCP ganham campo opcional `autoSpawn: boolean` no inputSchema:
61
+ - `sync` (action=install)
62
+ - `reverse-sync` (action=apply)
63
+ - `gates` (nova action `run`, com autoSpawn)
64
+ - Tools triviais (`kit`, `forensics`, `install`) **não** ganham autoSpawn — explicit-out por design
65
+
66
+ ### Adicionado — Phase 18: Hardening + release
67
+ - 3 hardening tests novos: kill -9 recovery, multi-publisher race, MCP stdio uncorrupted (validação rigorosa do REQ SEC-04 em produção)
68
+ - README seção "Live UI" com primeiros passos
69
+ - `npm pack --dry-run` valida que `src/ui/static/index.html` é incluído no tarball
70
+
71
+ ### Corrigido
72
+ - **REL-01 (bug pré-existente):** `kit --version` agora lê de `package.json` em vez de retornar string hardcoded `1.0.0`. Em v1.0/v1.1 o comando exibia versão errada — corrigido nesta release.
73
+
74
+ ### Stable API additions (1.x compatible)
75
+
76
+ A v1.0 commitment continua válida. Estas adições são parte do contrato:
77
+
78
+ - **MCP tool `sync` inputSchema:** campo opcional `autoSpawn: boolean` em action=install. Tools que não passam mantêm comportamento idêntico.
79
+ - **MCP tool `reverse-sync` inputSchema:** campo opcional `autoSpawn: boolean` em action=apply.
80
+ - **MCP tool `gates` inputSchema:** campo opcional `autoSpawn: boolean` E nova action `run` com `id`/`projectRoot`/`autoSpawn` campos.
81
+ - **CLI subgroup `kit ui`:** novo grupo com `start | stop | status | open` subcommands.
82
+ - **CLI flag `--no-ui` global** + env var `KIT_MCP_NO_UI=1` — opt-out do auto-detect de sidecar.
83
+ - **Stable runtime guarantee:** core (`syncTo`, `applyReverse`, `runGate`) é literalmente intocado. Wrapper de `onProgress` é montado APENAS no callsite (CLI handler ou MCP tool handler).
84
+
85
+ ### Migration
86
+
87
+ **Usuários v1.1 não precisam fazer nada.** Sidecar é estritamente opt-in.
88
+
89
+ Para experimentar a UI:
90
+ ```bash
91
+ # 1. Em um terminal:
92
+ kit ui start
93
+
94
+ # 2. Em outro (ou via Claude Code/Cursor):
95
+ kit sync install claude-code
96
+
97
+ # A janela mostra o progresso em tempo real.
98
+ ```
99
+
100
+ Para tools MCP, passe `autoSpawn: true` quando quiser auto-abrir:
101
+ ```jsonc
102
+ { "tool": "sync", "arguments": { "action": "install", "target": "claude-code", "autoSpawn": true } }
103
+ ```
104
+
105
+ ### Threat model resumido
106
+
107
+ Sidecar é **localhost only**, single-user, dev workstation. Sem auth (mitigado por bind 127.0.0.1 + Host/Origin check + CSP estrito + path scrubbing). Sem persistência. Sem TLS (loopback). Detalhes em [`docs/sidecar-security.md`](docs/sidecar-security.md).
108
+
9
109
  ## [1.1.0] - 2026-05-03
10
110
 
11
111
  **Visual feedback in the terminal.** Running `kit ...` now prints colored tables, progress bars, summary panels and interactive selectors instead of the raw JSON-to-stdout default of v1.0. Programmatic consumers add `--json` to restore the previous behavior.
package/README.md CHANGED
@@ -272,6 +272,38 @@ kit forensics load-replay <id> --project-root .
272
272
 
273
273
  The proposal is always saved to `.planning/learnings/{agent}.proposal.md` first; the canonical is only modified after explicit confirmation (or `--apply`). MCP `forensics.reflect` never auto-applies.
274
274
 
275
+ ### `kit ui ...` — live process viewer (sidecar) — _new in 1.2_
276
+
277
+ A tiny localhost web app that streams what kit-mcp is doing while your IDE drives it. Strictly opt-in: ignore it and v1.1 behavior is unchanged.
278
+
279
+ ```bash
280
+ # In one terminal — keeps running until Ctrl+C
281
+ kit ui start
282
+
283
+ # In another terminal (or via Claude Code / Cursor) — runs as before, but events
284
+ # are now also broadcast to the sidecar window
285
+ kit sync install claude-code
286
+
287
+ # Tools too: pass autoSpawn:true on the MCP side, or just `kit ui start` first
288
+ kit ui status
289
+ kit ui stop
290
+ ```
291
+
292
+ What you get:
293
+
294
+ - A single browser tab at `http://127.0.0.1:7100` (or the next free port up to 7199)
295
+ - Live event stream over Server-Sent Events — `tool_invocation`, `progress`, `error`, `milestone`, `run.start`, `run.end`
296
+ - Filters by event type and substring; pause/resume; auto-scroll; dark mode tracks the OS
297
+ - The sidecar shuts itself down after 30 minutes of idle; `--idle-ms 0` disables that
298
+
299
+ **Auto-spawn from MCP tools.** Pass `autoSpawn: true` in the inputSchema of `sync` (action=install), `reverse-sync` (action=apply), or `gates` (action=run). The MCP server will spawn `bin/ui.js` detached, wait for it to come online, open your default browser, and stream that tool's progress. Trivial tools (`kit list-*`, `forensics`, `install`) deliberately don't accept `autoSpawn` — the overhead isn't worth it.
300
+
301
+ **Opt-out always available.** From the CLI: pass `--no-ui` or set `KIT_MCP_NO_UI=1`. The sidecar is never started behind your back; it's only used when a lockfile is already present (someone ran `kit ui start` or `autoSpawn: true`).
302
+
303
+ **Security model.** Sidecar binds to `127.0.0.1` literally — never `0.0.0.0`, never `localhost` (which resolves to `::1` on Windows). Every route validates the `Host` header to mitigate DNS rebinding. CSP is strict (`default-src 'self'; …; frame-ancestors 'none'`). Paths in payloads are scrubbed (`$HOME → ~`, `<projectRoot> → <project>`) so screenshots don't leak directory structure. No persistence, no TLS, no auth — single-user dev workstation only. Full threat model in [`docs/sidecar-security.md`](docs/sidecar-security.md).
304
+
305
+ **First-run quirks.** Windows Defender / macOS firewall may prompt the first time `kit ui start` binds. Approving "Private networks" is enough — the server doesn't accept anything from outside loopback regardless. On WSL, `kit ui start` opens the URL in the Windows host browser via `wslview`. In CI / SSH / `TERM=dumb`, browser launch is suppressed and the URL is printed to stderr instead.
306
+
275
307
  ---
276
308
 
277
309
  ## MCP usage
package/bin/ui.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // bin/ui.js — entry point for the sidecar HTTP server.
3
+ //
4
+ // Used both directly (when the user runs `kit ui start`) and as the spawn target
5
+ // when `--auto-spawn` is enabled on an MCP tool.
6
+ //
7
+ // Logging discipline: all output goes to stderr. stdout is reserved so that
8
+ // callers can pipe `node bin/ui.js | jq` without UI server chatter contaminating
9
+ // data streams (and so that this file can never poison an MCP stdio channel).
10
+
11
+ import process from 'node:process';
12
+ import { createServer } from '../src/ui/server.js';
13
+
14
+ function parseArgs(argv) {
15
+ const args = { projectRoot: process.cwd(), port: undefined, idleMs: undefined };
16
+ for (let i = 0; i < argv.length; i += 1) {
17
+ const a = argv[i];
18
+ if (a === '--project-root' && argv[i + 1]) { args.projectRoot = argv[i + 1]; i += 1; }
19
+ else if (a === '--port' && argv[i + 1]) { args.port = Number(argv[i + 1]); i += 1; }
20
+ else if (a === '--idle-ms' && argv[i + 1]) { args.idleMs = Number(argv[i + 1]); i += 1; }
21
+ else if (a === '--version') { args.printVersion = true; }
22
+ else if (a === '--help' || a === '-h') { args.help = true; }
23
+ }
24
+ return args;
25
+ }
26
+
27
+ const args = parseArgs(process.argv.slice(2));
28
+
29
+ if (args.help) {
30
+ process.stderr.write([
31
+ 'kit-mcp sidecar entry — usually invoked via `kit ui start`',
32
+ '',
33
+ 'Usage: node bin/ui.js [options]',
34
+ ' --project-root <path> project root for lockfile keying (default: cwd)',
35
+ ' --port <n> bind to a specific port (default: auto-pick 7100-7199)',
36
+ ' --idle-ms <ms> idle shutdown timeout (default: 1800000 = 30min; 0 = never)',
37
+ ' --version print version and exit',
38
+ ' --help this text',
39
+ '',
40
+ ].join('\n'));
41
+ process.exit(0);
42
+ }
43
+
44
+ let pkgVersion = null;
45
+ try {
46
+ const { default: pkg } = await import('../package.json', { with: { type: 'json' } });
47
+ pkgVersion = pkg.version;
48
+ } catch {
49
+ // ok — version may not be available in some packaged contexts
50
+ }
51
+
52
+ if (args.printVersion) {
53
+ process.stderr.write(`${pkgVersion ?? 'unknown'}\n`);
54
+ process.exit(0);
55
+ }
56
+
57
+ const server = createServer({
58
+ projectRoot: args.projectRoot,
59
+ version: pkgVersion,
60
+ idleMs: args.idleMs,
61
+ });
62
+
63
+ try {
64
+ const { port } = await server.start({ port: args.port });
65
+ process.stderr.write(`[kit-mcp ui] listening on http://127.0.0.1:${port}/\n`);
66
+ process.stderr.write(`[kit-mcp ui] project: ${args.projectRoot}\n`);
67
+ } catch (err) {
68
+ if (err.code === 'ELIVE') {
69
+ process.stderr.write(`[kit-mcp ui] sidecar already running for this project (pid=${err.lock?.pid}, port=${err.lock?.port})\n`);
70
+ process.exit(2);
71
+ }
72
+ process.stderr.write(`[kit-mcp ui] failed to start: ${err.message}\n`);
73
+ process.exit(1);
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luanpdd/kit-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Generic infrastructure to ship YOUR personal kit of agents/commands/skills as an MCP server, with cross-IDE sync (Claude Code, Cursor, Codex, Gemini, Windsurf, Antigravity, Copilot, Trae).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,6 +51,7 @@
51
51
  "@modelcontextprotocol/sdk": "^1.0.0",
52
52
  "chokidar": "^5.0.0",
53
53
  "commander": "^12.1.0",
54
+ "open": "^11.0.0",
54
55
  "picocolors": "^1.1.1"
55
56
  }
56
57
  }
package/src/cli/index.js CHANGED
@@ -13,6 +13,9 @@
13
13
  // programmatic consumers.
14
14
 
15
15
  import { Command } from 'commander';
16
+ import { readFileSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import path from 'node:path';
16
19
  import { listKit, searchKit, findItem } from '../core/kit.js';
17
20
  import { listTargets } from '../core/registry.js';
18
21
  import { syncTo, statusOf, removeFrom } from '../core/sync.js';
@@ -26,13 +29,31 @@ import { listReplays, loadReplay } from '../core/replays.js';
26
29
  import { installMcp, listInstallTargets } from '../mcp-server/install.js';
27
30
  import * as render from './render.js';
28
31
  import { c, icons, spinner, progress, select, confirm } from '../core/ui.js';
32
+ import { createServer } from '../ui/server.js';
33
+ import { readLock, lockPathFor } from '../ui/lockfile.js';
34
+ import { wrapProgressForUi } from '../ui/wrapper.js';
35
+ import { openBrowser } from '../ui/browser.js';
36
+ import http from 'node:http';
37
+
38
+ // Read package.json version at boot so `--version` is always accurate. Falls
39
+ // back to a string if the file lookup fails (e.g. unusual install layout).
40
+ function readPkgVersion() {
41
+ try {
42
+ const here = path.dirname(fileURLToPath(import.meta.url));
43
+ const pkgPath = path.resolve(here, '..', '..', 'package.json');
44
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
45
+ } catch {
46
+ return 'unknown';
47
+ }
48
+ }
29
49
 
30
50
  const program = new Command()
31
51
  .name('kit')
32
52
  .description('Personal kit (agents/commands/skills) — CLI mirror of the kit-mcp server.')
33
- .version('1.0.0')
53
+ .version(readPkgVersion())
34
54
  .option('--kit-root <path>', 'Override the kit root (default: bundled example kit, or KIT_MCP_KIT_ROOT env)')
35
- .option('--json', 'Output JSON to stdout (machine-readable, restores pre-1.1 default)');
55
+ .option('--json', 'Output JSON to stdout (machine-readable, restores pre-1.1 default)')
56
+ .option('--no-ui', 'Suppress sidecar event publishing for this run (default: auto-detect lockfile)');
36
57
 
37
58
  program.hook('preAction', (thisCommand, actionCommand) => {
38
59
  const opts = program.opts();
@@ -67,22 +88,57 @@ async function withSpinner(text, fn) {
67
88
  }
68
89
 
69
90
  // withProgress wraps a long op; passes onProgress callback to the core fn.
70
- async function withProgress(label, total, fn) {
91
+ // Also auto-detects a running sidecar (via lockfile) and multiplexes events to
92
+ // it when present. Opt-out via --no-ui or KIT_MCP_NO_UI=1.
93
+ async function withProgress(label, total, fn, { tool, projectRoot } = {}) {
71
94
  const opts = program.opts();
72
- if (opts.json) return fn(() => {});
73
- const p = progress({ total, label });
74
- let last = '';
75
- const onProgress = ({ current, label }) => { last = label || last; p.tick({ label: last }); };
95
+ let onProgress;
96
+ let p = null;
97
+ if (opts.json) {
98
+ onProgress = () => {};
99
+ } else {
100
+ p = progress({ total, label });
101
+ let last = '';
102
+ onProgress = ({ current, label }) => { last = label || last; p.tick({ label: last }); };
103
+ }
104
+
105
+ // Auto-wrap if a sidecar is running for this projectRoot.
106
+ const wrapper = maybeWrapForUi(onProgress, { tool, projectRoot });
76
107
  try {
77
- const r = await fn(onProgress);
78
- p.finish(label);
108
+ const r = await fn(wrapper);
109
+ if (p) p.finish(label);
110
+ if (wrapper.done) wrapper.done({ ok: true });
79
111
  return r;
80
112
  } catch (e) {
81
- p.finish();
113
+ if (p) p.finish();
114
+ if (wrapper.error) wrapper.error(e);
82
115
  throw e;
83
116
  }
84
117
  }
85
118
 
119
+ // maybeWrapForUi — returns the original callback unchanged when no sidecar is up
120
+ // or the user opted out. Otherwise returns a wrapped callback with .done/.error.
121
+ function maybeWrapForUi(onProgress, { tool, projectRoot } = {}) {
122
+ const globalOpts = program.opts();
123
+ // commander stores `--no-ui` as opts.ui === false
124
+ if (globalOpts.ui === false || process.env.KIT_MCP_NO_UI === '1') {
125
+ return passthroughWrapper(onProgress);
126
+ }
127
+ const root = projectRoot || process.cwd();
128
+ if (!readLock(root)) {
129
+ return passthroughWrapper(onProgress);
130
+ }
131
+ return wrapProgressForUi(onProgress, { projectRoot: root, tool: tool ?? null });
132
+ }
133
+
134
+ function passthroughWrapper(onProgress) {
135
+ const cb = (p) => { if (typeof onProgress === 'function') onProgress(p); };
136
+ cb.done = () => {};
137
+ cb.error = () => {};
138
+ cb.emit = () => {};
139
+ return cb;
140
+ }
141
+
86
142
  function fail(msg) {
87
143
  process.stderr.write(`${c.red(icons.cross)} ${msg}\n`);
88
144
  process.exit(1);
@@ -134,6 +190,7 @@ sync.command('install [target]')
134
190
  `Syncing kit → ${target}`,
135
191
  300,
136
192
  (onProgress) => syncTo(target, { projectRoot: opts.projectRoot, mode: opts.mode, dryRun: opts.dryRun, onProgress }),
193
+ { tool: 'sync.install', projectRoot: opts.projectRoot },
137
194
  );
138
195
  out(result, render.renderSyncInstall);
139
196
  });
@@ -181,6 +238,7 @@ reverse.command('apply <target>')
181
238
  `Applying reverse-sync (${opts.strategy})`,
182
239
  50,
183
240
  (onProgress) => applyReverse(target, { projectRoot: opts.projectRoot, strategy: opts.strategy, only: opts.only, dryRun: opts.dryRun, onProgress }),
241
+ { tool: 'reverse-sync.apply', projectRoot: opts.projectRoot },
184
242
  );
185
243
  out(result, render.renderReverseApply);
186
244
  });
@@ -314,4 +372,147 @@ async function pickTarget(targets, message) {
314
372
  }
315
373
  }
316
374
 
375
+ // --- ui (sidecar process viewer) ---
376
+ const ui = program.command('ui').description('Live process viewer in a localhost browser tab.');
377
+
378
+ ui.command('start')
379
+ .description('Start the sidecar HTTP server in foreground (Ctrl+C to stop). Prints URL on stderr.')
380
+ .option('--project-root <path>', 'Project root for lockfile keying (default: cwd)')
381
+ .option('--port <n>', 'Bind to a specific port (default: auto-pick 7100-7199)')
382
+ .option('--idle-ms <ms>', 'Idle shutdown timeout (default 30min; 0 = never)')
383
+ .option('--no-open', 'Skip auto-opening the browser')
384
+ .action(async (opts) => {
385
+ const projectRoot = opts.projectRoot || process.cwd();
386
+ const port = opts.port ? Number(opts.port) : undefined;
387
+ const idleMs = opts.idleMs !== undefined ? Number(opts.idleMs) : undefined;
388
+ const srv = createServer({ projectRoot, idleMs });
389
+ try {
390
+ const { port: actualPort } = await srv.start({ port });
391
+ const url = `http://127.0.0.1:${actualPort}/`;
392
+ process.stderr.write(`${c.cyan(icons.info)} kit-mcp ui listening on ${url}\n`);
393
+ process.stderr.write(`${c.dim(` project: ${projectRoot}`)}\n`);
394
+ if (opts.open !== false) {
395
+ await openBrowser(url);
396
+ }
397
+ // The server's own SIGINT handler will perform shutdown + cleanup.
398
+ // We just stay alive — server is foreground.
399
+ } catch (err) {
400
+ if (err.code === 'ELIVE') {
401
+ process.stderr.write(`${c.yellow(icons.warn)} sidecar already running for this project\n`);
402
+ process.stderr.write(` pid: ${err.lock?.pid}, port: ${err.lock?.port}\n`);
403
+ process.stderr.write(` use 'kit ui status' or 'kit ui open' to inspect\n`);
404
+ process.exit(2);
405
+ }
406
+ fail(err.message);
407
+ }
408
+ });
409
+
410
+ ui.command('stop')
411
+ .description('Stop the sidecar running for this project (POST /shutdown).')
412
+ .option('--project-root <path>')
413
+ .action(async (opts) => {
414
+ const projectRoot = opts.projectRoot || process.cwd();
415
+ const lock = readLock(projectRoot);
416
+ if (!lock) return out({ ok: false, reason: 'no_sidecar' }, () => `${icons.warn} no sidecar running for this project\n`);
417
+ try {
418
+ await postShutdown(lock.port);
419
+ out({ ok: true, port: lock.port }, () => `${icons.check} sidecar at port ${lock.port} stopped\n`);
420
+ } catch (err) {
421
+ fail(`could not stop sidecar at port ${lock.port}: ${err.message}`);
422
+ }
423
+ });
424
+
425
+ ui.command('status')
426
+ .description('Show whether a sidecar is running for this project.')
427
+ .option('--project-root <path>')
428
+ .action(async (opts) => {
429
+ const projectRoot = opts.projectRoot || process.cwd();
430
+ const lock = readLock(projectRoot);
431
+ if (!lock) {
432
+ out({ running: false, reason: 'no_lockfile' }, () => `${icons.warn} no sidecar running\n`);
433
+ process.exit(1);
434
+ }
435
+ try {
436
+ const health = await getHealthz(lock.port);
437
+ out({ running: true, ...health, lockfile: lockPathFor(projectRoot) }, render.renderUiStatus ?? renderUiStatusFallback);
438
+ } catch (err) {
439
+ out({ running: false, reason: 'unreachable', lockfile: lockPathFor(projectRoot), error: err.message },
440
+ () => `${icons.cross} lockfile present but sidecar unreachable: ${err.message}\n`);
441
+ process.exit(1);
442
+ }
443
+ });
444
+
445
+ ui.command('open')
446
+ .description('Open the running sidecar in a browser. Fails if no sidecar is up.')
447
+ .option('--project-root <path>')
448
+ .action(async (opts) => {
449
+ const projectRoot = opts.projectRoot || process.cwd();
450
+ const lock = readLock(projectRoot);
451
+ if (!lock) return fail('no sidecar running — start one with `kit ui start`');
452
+ const url = `http://127.0.0.1:${lock.port}/`;
453
+ const r = await openBrowser(url, { force: true });
454
+ if (!r.opened) {
455
+ process.stderr.write(`${c.yellow(icons.warn)} could not open browser (${r.reason}); copy the URL above\n`);
456
+ process.exit(1);
457
+ }
458
+ });
459
+
460
+ // Helpers for kit ui (live in cli/ — stdout/console allowed here)
461
+ async function postShutdown(port) {
462
+ return new Promise((resolve, reject) => {
463
+ const req = http.request({
464
+ method: 'POST',
465
+ host: '127.0.0.1',
466
+ port,
467
+ path: '/shutdown',
468
+ agent: false,
469
+ headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'content-length': 0, connection: 'close' },
470
+ }, (res) => {
471
+ res.resume();
472
+ res.on('end', () => res.statusCode < 400 ? resolve() : reject(new Error(`http_${res.statusCode}`)));
473
+ });
474
+ req.on('error', reject);
475
+ req.setTimeout(2000, () => { try { req.destroy(); } catch {} ; reject(new Error('timeout')); });
476
+ req.end();
477
+ });
478
+ }
479
+
480
+ async function getHealthz(port) {
481
+ return new Promise((resolve, reject) => {
482
+ const req = http.request({
483
+ method: 'GET',
484
+ host: '127.0.0.1',
485
+ port,
486
+ path: '/healthz',
487
+ agent: false,
488
+ headers: { host: `127.0.0.1:${port}`, connection: 'close' },
489
+ }, (res) => {
490
+ const chunks = [];
491
+ res.on('data', (c) => chunks.push(c));
492
+ res.on('end', () => {
493
+ if (res.statusCode >= 400) return reject(new Error(`http_${res.statusCode}`));
494
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
495
+ catch (e) { reject(e); }
496
+ });
497
+ });
498
+ req.on('error', reject);
499
+ req.setTimeout(2000, () => { try { req.destroy(); } catch {} ; reject(new Error('timeout')); });
500
+ req.end();
501
+ });
502
+ }
503
+
504
+ function renderUiStatusFallback(v) {
505
+ if (!v.running) return `${icons.warn} not running\n`;
506
+ return [
507
+ `${icons.check} sidecar running`,
508
+ ` port: ${v.port}`,
509
+ ` pid (sdcr): ${v.lockfile ? readLock(process.cwd())?.pid : '?'}`,
510
+ ` uptime: ${Math.round((v.uptime || 0) / 1000)}s`,
511
+ ` events: ${v.eventsTotal}`,
512
+ ` subscribers: ${v.subscribers}`,
513
+ ` url: http://127.0.0.1:${v.port}/`,
514
+ '',
515
+ ].join('\n');
516
+ }
517
+
317
518
  program.parseAsync(process.argv);
@@ -17,10 +17,13 @@ import { listTargets } from '../core/registry.js';
17
17
  import { syncTo, statusOf, removeFrom } from '../core/sync.js';
18
18
  import { detectReverse, applyReverse } from '../core/reverse-sync.js';
19
19
  import { listGates, getGate, gatesForStage } from '../core/gates.js';
20
+ import { runGate } from '../core/gate-runner.js';
20
21
  import { collectFailures, summarizeByAgent, writeLearnings } from '../core/failures.js';
21
22
  import { reflect } from '../core/reflect.js';
22
23
  import { recordReplay, listReplays, loadReplay, annotateReplay } from '../core/replays.js';
23
24
  import { installMcp, listInstallTargets } from './install.js';
25
+ import { ensureSidecar } from '../ui/auto-spawn.js';
26
+ import { wrapProgressForUi } from '../ui/wrapper.js';
24
27
 
25
28
  const TOOLS = [
26
29
  {
@@ -48,6 +51,7 @@ const TOOLS = [
48
51
  projectRoot: { type: 'string', description: 'Defaults to cwd' },
49
52
  mode: { type: 'string', enum: ['reference', 'copy'], description: 'Default: reference' },
50
53
  dryRun: { type: 'boolean' },
54
+ autoSpawn: { type: 'boolean', description: 'On action=install: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
51
55
  },
52
56
  required: ['action'],
53
57
  },
@@ -64,19 +68,22 @@ const TOOLS = [
64
68
  strategy: { type: 'string', enum: ['skip', 'overwrite', 'merge', 'rename'], description: 'For action=apply' },
65
69
  only: { type: 'array', items: { type: 'string' }, description: 'For action=apply: limit to these kind/name pairs' },
66
70
  dryRun: { type: 'boolean' },
71
+ autoSpawn: { type: 'boolean', description: 'On action=apply: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
67
72
  },
68
73
  required: ['action', 'target'],
69
74
  },
70
75
  },
71
76
  {
72
77
  name: 'gates',
73
- description: 'List or fetch reusable workflow gates (regression, confidence, etc).',
78
+ description: 'List, fetch, or execute reusable workflow gates (regression, confidence, etc).',
74
79
  inputSchema: {
75
80
  type: 'object',
76
81
  properties: {
77
- action: { type: 'string', enum: ['list', 'get', 'for-stage'] },
78
- id: { type: 'string', description: 'For action=get' },
79
- stage: { type: 'string', enum: ['pre-plan', 'pre-execute', 'pre-verify', 'post-verify', 'any'], description: 'For action=for-stage' },
82
+ action: { type: 'string', enum: ['list', 'get', 'for-stage', 'run'] },
83
+ id: { type: 'string', description: 'For action=get or action=run' },
84
+ stage: { type: 'string', enum: ['pre-plan', 'pre-execute', 'pre-verify', 'post-verify', 'any'], description: 'For action=for-stage' },
85
+ projectRoot: { type: 'string', description: 'For action=run' },
86
+ autoSpawn: { type: 'boolean', description: 'On action=run: auto-start the sidecar UI (kit ui) if not running and stream progress to it.' },
80
87
  },
81
88
  required: ['action'],
82
89
  },
@@ -136,11 +143,38 @@ async function handleKit(args) {
136
143
  }
137
144
  }
138
145
 
146
+ // withAutoSpawn — if args.autoSpawn is set, ensure the sidecar is up and wrap
147
+ // the user-supplied onProgress so events flow there. Otherwise pass-through.
148
+ async function withAutoSpawn(args, tool, run) {
149
+ const projectRoot = args.projectRoot || process.cwd();
150
+ let wrapped = null;
151
+ let sidecarInfo = null;
152
+
153
+ if (args.autoSpawn) {
154
+ sidecarInfo = await ensureSidecar({ projectRoot, openBrowserOnSpawn: true });
155
+ if (sidecarInfo?.ready) {
156
+ wrapped = wrapProgressForUi(null, { projectRoot, tool });
157
+ }
158
+ }
159
+
160
+ // run(onProgress) — pass our wrapped callback (or undefined to no-op)
161
+ try {
162
+ const result = await run(wrapped);
163
+ if (wrapped?.done) wrapped.done({ ok: true });
164
+ return sidecarInfo ? { ...result, _sidecar: sidecarInfo } : result;
165
+ } catch (err) {
166
+ if (wrapped?.error) wrapped.error(err);
167
+ throw err;
168
+ }
169
+ }
170
+
139
171
  async function handleSync(args) {
140
172
  switch (args.action) {
141
173
  case 'targets': return listTargets();
142
174
  case 'status': return statusOf(args.target, { projectRoot: args.projectRoot });
143
- case 'install': return syncTo(args.target, { projectRoot: args.projectRoot, mode: args.mode, dryRun: args.dryRun });
175
+ case 'install':
176
+ return withAutoSpawn(args, 'sync.install', (onProgress) =>
177
+ syncTo(args.target, { projectRoot: args.projectRoot, mode: args.mode, dryRun: args.dryRun, onProgress }));
144
178
  case 'remove': return removeFrom(args.target, { projectRoot: args.projectRoot });
145
179
  default: return { error: `Unknown action: ${args.action}` };
146
180
  }
@@ -149,7 +183,13 @@ async function handleSync(args) {
149
183
  async function handleReverseSync(args) {
150
184
  switch (args.action) {
151
185
  case 'detect': return detectReverse(args.target, { projectRoot: args.projectRoot });
152
- case 'apply': return applyReverse(args.target, { projectRoot: args.projectRoot, strategy: args.strategy, only: args.only, dryRun: args.dryRun });
186
+ case 'apply':
187
+ return withAutoSpawn(args, 'reverse-sync.apply', (onProgress) =>
188
+ applyReverse(args.target, {
189
+ projectRoot: args.projectRoot,
190
+ strategy: args.strategy, only: args.only, dryRun: args.dryRun,
191
+ onProgress,
192
+ }));
153
193
  default: return { error: `Unknown action: ${args.action}` };
154
194
  }
155
195
  }
@@ -159,6 +199,13 @@ async function handleGates(args) {
159
199
  case 'list': return listGates();
160
200
  case 'get': return getGate(args.id);
161
201
  case 'for-stage': return gatesForStage(args.stage);
202
+ case 'run':
203
+ return withAutoSpawn(args, 'gates.run', () =>
204
+ runGate(args.id, {
205
+ projectRoot: args.projectRoot,
206
+ yes: true, // MCP context: never prompt
207
+ interactive: false, // MCP never prompts
208
+ }));
162
209
  default: return { error: `Unknown action: ${args.action}` };
163
210
  }
164
211
  }