{"version":3,"file":"team-auto.d.ts","sourceRoot":"","sources":["../../src/core/team-auto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAUH;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAWnE;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAc9C;AAmBD,mEAAmE;AACnE,wBAAgB,uBAAuB,CACtC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAClC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,SAAS,CAIvD;AAED,MAAM,WAAW,QAAQ;IACxB,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,0EAA0E;IAC1E,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC/B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,sDAAsD;IACtD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACxB;AAkCD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAoDjG","sourcesContent":["/**\n * `--team auto`: discover a team config, spawn a local hooteams server as a\n * child process, and hand back its URL so the rest of the pipeline behaves\n * exactly as if `--team http://localhost:<port>` had been passed.\n *\n * hooteams is intentionally not bundled — the launcher is resolved from PATH\n * (`hooteams`, falling back to `bunx hooteams`) and missing pieces fail with\n * a clear, actionable error. The child is reaped on hoocode exit, clean or\n * signalled, via a process \"exit\" hook (the interactive shutdown path calls\n * process.exit directly, so an async cleanup would never run).\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { accessSync, constants, existsSync } from \"node:fs\";\nimport { createServer } from \"node:net\";\nimport path from \"node:path\";\n\n/** Config locations probed at each directory level, in priority order. */\nconst TEAM_CONFIG_CANDIDATES = [path.join(\".agents\", \"teams\", \"default.json\"), \"hooteams.config.json\"];\n\n/**\n * Walk up from startDir to the filesystem root, returning the first config\n * found. Both candidates are probed per level (.agents/teams/default.json\n * wins over hooteams.config.json in the same directory).\n */\nexport function findTeamConfig(startDir: string): string | undefined {\n\tlet dir = path.resolve(startDir);\n\twhile (true) {\n\t\tfor (const candidate of TEAM_CONFIG_CANDIDATES) {\n\t\t\tconst candidatePath = path.join(dir, candidate);\n\t\t\tif (existsSync(candidatePath)) return candidatePath;\n\t\t}\n\t\tconst parent = path.dirname(dir);\n\t\tif (parent === dir) return undefined;\n\t\tdir = parent;\n\t}\n}\n\n/** Ask the OS for a free port by binding port 0 and reading the assignment. */\nexport function findFreePort(): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst server = createServer();\n\t\tserver.once(\"error\", reject);\n\t\tserver.listen(0, \"127.0.0.1\", () => {\n\t\t\tconst address = server.address();\n\t\t\tif (address === null || typeof address === \"string\") {\n\t\t\t\tserver.close(() => reject(new Error(\"could not determine a free port\")));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst { port } = address;\n\t\t\tserver.close(() => resolve(port));\n\t\t});\n\t});\n}\n\nfunction isExecutableOnPath(name: string, env: NodeJS.ProcessEnv): boolean {\n\tconst pathVar = env.PATH ?? \"\";\n\tconst extensions = process.platform === \"win32\" ? (env.PATHEXT ?? \".EXE;.CMD;.BAT;.COM\").split(\";\") : [\"\"];\n\tfor (const dir of pathVar.split(path.delimiter)) {\n\t\tif (!dir) continue;\n\t\tfor (const extension of extensions) {\n\t\t\ttry {\n\t\t\t\taccessSync(path.join(dir, name + extension.toLowerCase()), constants.X_OK);\n\t\t\t\treturn true;\n\t\t\t} catch {\n\t\t\t\t// keep probing\n\t\t\t}\n\t\t}\n\t}\n\treturn false;\n}\n\n/** How to launch hooteams: directly from PATH, or through bunx. */\nexport function resolveHooteamsLauncher(\n\tenv: NodeJS.ProcessEnv = process.env,\n): { command: string; prefixArgs: string[] } | undefined {\n\tif (isExecutableOnPath(\"hooteams\", env)) return { command: \"hooteams\", prefixArgs: [] };\n\tif (isExecutableOnPath(\"bunx\", env)) return { command: \"bunx\", prefixArgs: [\"hooteams\"] };\n\treturn undefined;\n}\n\nexport interface AutoTeam {\n\t/** Base URL of the spawned hooteams server. */\n\turl: string;\n\t/** Graceful shutdown: POST /stop, then kill the child's process group. */\n\tstop(): Promise<void>;\n}\n\nexport interface AutoTeamOptions {\n\t/** Startup progress sink (pre-TUI, so console is fine). */\n\tlog?: (message: string) => void;\n\t/** How long to wait for GET /health (default 15s). */\n\thealthTimeoutMs?: number;\n\tenv?: NodeJS.ProcessEnv;\n}\n\nfunction killChild(child: ChildProcess): void {\n\tif (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;\n\ttry {\n\t\t// POSIX: the child leads its own process group (detached), so a negative\n\t\t// pid reaches hooteams even when launched through a bunx wrapper.\n\t\tif (process.platform !== \"win32\") process.kill(-child.pid, \"SIGTERM\");\n\t\telse child.kill();\n\t} catch {\n\t\t// Already gone.\n\t}\n}\n\nasync function waitForHealth(url: string, child: ChildProcess, timeoutMs: number): Promise<void> {\n\tconst deadline = Date.now() + timeoutMs;\n\twhile (Date.now() < deadline) {\n\t\tif (child.exitCode !== null || child.signalCode !== null) {\n\t\t\tthrow new Error(`--team auto: hooteams exited (code ${child.exitCode ?? \"signal\"}) before becoming healthy`);\n\t\t}\n\t\ttry {\n\t\t\tconst response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });\n\t\t\tif (response.ok) {\n\t\t\t\tconst body = (await response.json()) as { ok?: boolean };\n\t\t\t\tif (body.ok === true) return;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Not up yet; keep polling.\n\t\t}\n\t\tawait new Promise((resolve) => setTimeout(resolve, 150));\n\t}\n\tthrow new Error(`--team auto: hooteams did not report healthy at ${url}/health within ${timeoutMs}ms`);\n}\n\n/**\n * Resolve the config, spawn hooteams on a free port, and wait for /health.\n * Throws (with a message ready for the terminal) when no config is found, no\n * launcher resolves, or the server never becomes healthy.\n */\nexport async function startAutoTeam(cwd: string, options: AutoTeamOptions = {}): Promise<AutoTeam> {\n\tconst env = options.env ?? process.env;\n\tconst config = findTeamConfig(cwd);\n\tif (!config) {\n\t\tthrow new Error(\n\t\t\t`--team auto: no team config found. Looked for ${TEAM_CONFIG_CANDIDATES.join(\" or \")} in ${cwd} and every parent directory.`,\n\t\t);\n\t}\n\tconst launcher = resolveHooteamsLauncher(env);\n\tif (!launcher) {\n\t\tthrow new Error(\n\t\t\t\"--team auto: hooteams is not on PATH and bunx is unavailable. Install hooteams (or bun) or pass --team <url> to use a running server.\",\n\t\t);\n\t}\n\n\tconst port = await findFreePort();\n\tconst url = `http://localhost:${port}`;\n\toptions.log?.(`Starting hooteams (config ${config}) on port ${port}…`);\n\n\tconst child = spawn(\n\t\tlauncher.command,\n\t\t[...launcher.prefixArgs, \"start\", \"--config\", config, \"--port\", String(port)],\n\t\t{\n\t\t\tstdio: \"ignore\",\n\t\t\tenv,\n\t\t\tdetached: process.platform !== \"win32\",\n\t\t},\n\t);\n\tchild.unref();\n\tconst reapOnExit = () => killChild(child);\n\tprocess.on(\"exit\", reapOnExit);\n\n\ttry {\n\t\tawait waitForHealth(url, child, options.healthTimeoutMs ?? 15000);\n\t} catch (error) {\n\t\tprocess.off(\"exit\", reapOnExit);\n\t\tkillChild(child);\n\t\tthrow error;\n\t}\n\n\treturn {\n\t\turl,\n\t\tasync stop() {\n\t\t\tprocess.off(\"exit\", reapOnExit);\n\t\t\ttry {\n\t\t\t\tawait fetch(`${url}/stop`, { method: \"POST\", signal: AbortSignal.timeout(2000) });\n\t\t\t} catch {\n\t\t\t\t// Graceful stop is best-effort; the kill below is the guarantee.\n\t\t\t}\n\t\t\tkillChild(child);\n\t\t},\n\t};\n}\n"]}