diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 7dd8e18..c6c2924 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -162,6 +162,7 @@ services: PORT: 18082 DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:-dev-ai-workspace-assistant-token} + AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-ws://host.docker.internal:18081/api/ai-workspace/hub} expose: - "18082" ports: diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index a4c2771..ee23d62 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -1,5 +1,6 @@ import express from "express"; -import { randomUUID, timingSafeEqual } from "node:crypto"; +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { Pool } from "pg"; @@ -83,6 +84,42 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/select", requireI res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: executor.id, executor }); })); +app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const executorId = sanitizeUuid(req.params.executorId, "executorId"); + const executor = await getExecutor(owner, executorId); + if (!executor) { + res.status(404).send("executor_not_found"); + return; + } + const installerPort = installerPortFromQuery(req.query.port); + if (!installerPort) { + res.status(400).send("installer_port_invalid"); + return; + } + + const templateUrl = new URL("./templates/install-windows.ps1", import.meta.url); + let source = await readFile(templateUrl, "utf8"); + source = source + .replace('[string]$HubUrl = "",', `[string]$HubUrl = ${psSingle(config.hubWebSocketUrl)},`) + .replace('[string]$HubFallbackUrls = "",', `[string]$HubFallbackUrls = ${psSingle(config.hubFallbackWebSocketUrls.join(","))},`) + .replace('[string]$PairingCode = "",', `[string]$PairingCode = ${psSingle(executor.pairingCode || "")},`) + .replace('[string]$MachineName = "",', `[string]$MachineName = ${psSingle(executor.name)},`) + .replace('[int]$Port = 8787,', `[int]$Port = ${installerPort},`) + .replace('[string]$Workspace = "",', `[string]$Workspace = ${psSingle(executor.workspacePath || "")},`); + + const installerHash = createHash("sha256").update(source).digest("hex"); + const safeName = optionalString(executor.name)?.replace(/[^A-Za-z0-9._-]+/g, "_") || "NDC"; + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + res.setHeader("Surrogate-Control", "no-store"); + res.setHeader("X-AI-Workspace-Installer-SHA256", installerHash); + res.setHeader("Content-Disposition", `attachment; filename="${safeName}_BRIDGE-agent-install.ps1"`); + res.send(source); +})); + app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null; @@ -174,6 +211,9 @@ async function listExecutors(owner) { } async function createExecutor(owner, command) { + if (command.connectionMode === "hub" && !command.pairingCode) { + command.pairingCode = makePairingCode(); + } const result = await pool.query( `insert into ai_workspace_executors ( id, @@ -498,7 +538,7 @@ async function migrate() { pairing_code text, model text, account_label text, - capabilities jsonb not null default '{}'::jsonb, + capabilities jsonb not null default '[]'::jsonb, status text not null default 'unknown', status_detail text, last_seen_at timestamptz, @@ -599,7 +639,7 @@ function sanitizeExecutorCommand(payload, { partial }) { copyOptionalString(command, "model", source.model); copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label); if (!partial || Object.hasOwn(source, "capabilities")) { - command.capabilities = isJsonContainer(source.capabilities) ? source.capabilities : {}; + command.capabilities = sanitizeCapabilities(source.capabilities); } if (!partial || Object.hasOwn(source, "status")) { command.status = sanitizeEnum(source.status || "unknown", SUPPORTED_EXECUTOR_STATUSES, "unsupported_executor_status"); @@ -666,7 +706,7 @@ function getRequestOwner(req) { throw badRequest("ai_workspace_owner_required"); } return { - key: userId ? `user:${userId}` : `email:${email}`, + key: email ? `email:${email}` : `user:${userId}`, userId, email, }; @@ -695,7 +735,7 @@ function toExecutor(row) { pairingCode: row.pairing_code, model: row.model, accountLabel: row.account_label, - capabilities: row.capabilities || {}, + capabilities: sanitizeCapabilities(row.capabilities), status: row.status, statusDetail: row.status_detail, lastSeenAt: toIso(row.last_seen_at), @@ -787,6 +827,17 @@ function sanitizeToolPacks(value) { }); } +function sanitizeCapabilities(value) { + const items = Array.isArray(value) + ? value + : Array.isArray(value?.items) + ? value.items + : isPlainObject(value) + ? Object.entries(value).filter(([, enabled]) => enabled === true).map(([key]) => key) + : []; + return Array.from(new Set(items.map(normalizeKey).filter(Boolean))); +} + function sanitizeEnum(value, supported, errorMessage) { const key = normalizeKey(value); if (!supported.has(key)) { @@ -801,6 +852,32 @@ function sanitizeLimit(value, fallback, max) { return Math.min(Math.max(Math.trunc(limit), 1), max); } +function normalizePairingCode(value) { + return String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 32); +} + +function cleanPairingCode(value) { + const normalized = normalizePairingCode(value); + if (!normalized) return ""; + return normalized.replace(/(.{4})(?=.)/g, "$1-"); +} + +function makePairingCode() { + return cleanPairingCode(randomUUID().replace(/-/g, "").slice(0, 12)); +} + +function installerPortFromQuery(raw) { + const value = optionalString(raw); + if (!value) return 8787; + if (!/^\d{1,5}$/.test(value)) return null; + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null; +} + +function psSingle(value) { + return `'${String(value || "").replace(/'/g, "''")}'`; +} + function sanitizeUuid(value, name) { const text = optionalString(value); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text || "")) { @@ -821,7 +898,7 @@ function sanitizeOptionalIso(value, name) { function copyOptionalString(target, key, value) { if (value === undefined) return; - target[key] = optionalString(value); + target[key] = key === "pairingCode" ? cleanPairingCode(value) : optionalString(value); } function copyOptionalInteger(target, key, value, min, max) { @@ -862,10 +939,6 @@ function isPlainObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function isJsonContainer(value) { - return Boolean(value && typeof value === "object"); -} - function toIso(value) { if (!value) return null; return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); @@ -893,6 +966,19 @@ function readConfig() { port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"), databaseUrl, databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"), + hubWebSocketUrl: + process.env.AI_WORKSPACE_HUB_PUBLIC_URL || + process.env.NDC_AI_WORKSPACE_HUB_URL || + process.env.AI_WORKSPACE_HUB_URL || + "wss://ai-hub.nodedc.ru/api/ai-workspace/hub", + hubFallbackWebSocketUrls: String( + process.env.AI_WORKSPACE_HUB_FALLBACK_URLS || + process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS || + "" + ) + .split(/[\n,;]+/) + .map((item) => item.trim()) + .filter(Boolean), internalAccessToken: process.env.AI_WORKSPACE_ASSISTANT_TOKEN || process.env.NODEDC_INTERNAL_ACCESS_TOKEN || diff --git a/services/ai-workspace-assistant/src/templates/install-windows.ps1 b/services/ai-workspace-assistant/src/templates/install-windows.ps1 new file mode 100644 index 0000000..81ec5ce --- /dev/null +++ b/services/ai-workspace-assistant/src/templates/install-windows.ps1 @@ -0,0 +1,3808 @@ +# NDC AI Workspace Bridge installer for Windows. +# Run from an elevated PowerShell session: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\install-windows.ps1 + +[CmdletBinding()] +param( + [int]$Port = 8787, + [string]$InstallRoot = "$env:LOCALAPPDATA\NDC\AIWorkspaceBridge", + [string]$Workspace = "", + [string]$HubUrl = "", + [string]$HubFallbackUrls = "", + [string]$PairingCode = "", + [string]$MachineName = "", + [switch]$NoAutostart, + [switch]$NoFirewall, + [switch]$NoCodexLogin, + [switch]$EnableWatchdog, + [switch]$Confirmed +) + +$ErrorActionPreference = 'Stop' +$TaskName = 'NDC AI Workspace Bridge' +$HealthPath = '/api/ai-workspace/bridge/v1/health' +$ConversationPath = '/api/ai-workspace/bridge/v1/conversations' + +function Get-ResultPath { + if ($PSScriptRoot -and (Test-Path $PSScriptRoot)) { + return (Join-Path $PSScriptRoot 'install-result.txt') + } + return (Join-Path $InstallRoot 'install-result.txt') +} + +function Write-InstallResult { + param([string]$Text) + $resultPath = Get-ResultPath + try { + $resultDir = Split-Path -Parent $resultPath + if ($resultDir) { New-Item -ItemType Directory -Force -Path $resultDir | Out-Null } + Set-Content -Path $resultPath -Value $Text -Encoding UTF8 + Write-Host "" + Write-Host "Result file: $resultPath" -ForegroundColor Green + } catch { + Write-Warn "Could not write result file: $($_.Exception.Message)" + } +} + +function Write-Step { + param([string]$Text) + Write-Host "" + Write-Host "== $Text ==" -ForegroundColor Cyan +} + +function Write-Ok { + param([string]$Text) + Write-Host "[ok] $Text" -ForegroundColor Green +} + +function Write-Warn { + param([string]$Text) + Write-Host "[warn] $Text" -ForegroundColor Yellow +} + +function Test-Admin { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Refresh-Path { + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = "$machinePath;$userPath" +} + +function Add-UserPath { + param([string]$PathToAdd) + if (-not $PathToAdd -or -not (Test-Path $PathToAdd)) { return } + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $parts = @() + if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ } } + if ($parts -notcontains $PathToAdd) { + $nextPath = ($parts + $PathToAdd) -join ';' + [Environment]::SetEnvironmentVariable('Path', $nextPath, 'User') + } + Refresh-Path +} + +function Resolve-CommandSource { + param([string]$Name) + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + return $null +} + +function Get-NpmPath { + $command = Get-Command 'npm.cmd' -ErrorAction SilentlyContinue + if (-not $command) { $command = Get-Command 'npm' -ErrorAction SilentlyContinue } + if ($command) { return $command.Source } + return $null +} + +function Invoke-NpmInstallCodex { + $npmPath = Get-NpmPath + if (-not $npmPath) { throw 'npm was not found.' } + & $npmPath 'install' '-g' '@openai/codex' '--no-audit' '--no-fund' + if ($LASTEXITCODE -ne 0) { + throw "npm install -g @openai/codex exited with code $LASTEXITCODE" + } +} + +function Get-NpmGlobalPrefix { + $npmPath = Get-NpmPath + if (-not $npmPath) { throw 'npm was not found.' } + $prefix = (& $npmPath 'prefix' '-g') -join '' + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($prefix)) { + throw "npm prefix -g exited with code $LASTEXITCODE" + } + return $prefix.Trim() +} + +function Get-NpmVersion { + $npmPath = Get-NpmPath + if (-not $npmPath) { return $null } + return ((& $npmPath '--version') -join '').Trim() +} + +function ConvertTo-PsSingleQuoted { + param([string]$Value) + return "'" + ($Value -replace "'", "''") + "'" +} + +function Get-HubUrlCandidates { + $items = @() + if ($HubUrl) { $items += $HubUrl } + if ($HubFallbackUrls) { + $items += ($HubFallbackUrls -split '[,;\r\n]+' | Where-Object { $_ }) + } + $result = @() + foreach ($item in $items) { + $clean = ([string]$item).Trim() + if (-not $clean) { continue } + if ($result -notcontains $clean) { $result += $clean } + } + return @($result) +} + +function Test-BridgeWorkspaceCandidate { + param([string]$PathValue) + if (-not $PathValue) { return $false } + try { + $leaf = Split-Path -Leaf $PathValue + if ($leaf -and $leaf.Equals('ai-workspace-bridge', [StringComparison]::OrdinalIgnoreCase)) { return $true } + if ($InstallRoot -and $PathValue.StartsWith($InstallRoot, [StringComparison]::OrdinalIgnoreCase)) { return $true } + } catch {} + return $false +} + +function Resolve-WorkspaceCandidate { + param([string]$PathValue) + if (-not $PathValue) { return $null } + $candidate = $PathValue.Trim('"') + if (-not (Test-Path $candidate)) { return $candidate } + if (-not (Test-BridgeWorkspaceCandidate $candidate)) { return $candidate } + try { + $parent = Split-Path -Parent $candidate + if ($parent -and (Test-Path $parent)) { return $parent } + } catch {} + return $candidate +} + +function Get-DefaultWorkspaceCandidate { + if ($Workspace) { return $Workspace.Trim('"') } + + try { + $location = Get-Location + if ($location.Provider.Name -eq 'FileSystem' -and (Test-Path $location.ProviderPath)) { + $windowsRoot = [Environment]::GetFolderPath('Windows') + if (-not $windowsRoot -or -not $location.ProviderPath.StartsWith($windowsRoot, [StringComparison]::OrdinalIgnoreCase)) { + return (Resolve-WorkspaceCandidate $location.ProviderPath) + } + } + } catch {} + + if ($PSCommandPath) { + $scriptDir = Split-Path -Parent $PSCommandPath + if ($scriptDir -and (Test-Path $scriptDir)) { return (Resolve-WorkspaceCandidate $scriptDir) } + } + + return $env:USERPROFILE +} + +function Request-Elevation { + if (Test-Admin) { return } + if (-not $PSCommandPath) { + throw 'This installer needs Administrator rights when pasted directly. Save it as install-windows.ps1 and run it again.' + } + + Write-Warn 'Administrator rights are needed for Node install and Windows Firewall rule.' + $workspaceForElevated = Get-DefaultWorkspaceCandidate + $args = @( + '-NoProfile', + '-NoExit', + '-ExecutionPolicy', 'Bypass', + '-File', "`"$PSCommandPath`"", + '-Port', $Port, + '-InstallRoot', "`"$InstallRoot`"", + '-Workspace', "`"$workspaceForElevated`"", + '-Confirmed' + ) + if ($NoAutostart) { $args += '-NoAutostart' } + if ($NoFirewall) { $args += '-NoFirewall' } + if ($NoCodexLogin) { $args += '-NoCodexLogin' } + if ($EnableWatchdog) { $args += '-EnableWatchdog' } + if ($HubUrl) { $args += @('-HubUrl', "`"$HubUrl`"") } + if ($HubFallbackUrls) { $args += @('-HubFallbackUrls', "`"$HubFallbackUrls`"") } + if ($PairingCode) { $args += @('-PairingCode', "`"$PairingCode`"") } + if ($MachineName) { $args += @('-MachineName', "`"$MachineName`"") } + + Write-Host 'Opening an Administrator PowerShell window. Approve the Windows UAC prompt and continue there.' -ForegroundColor Yellow + Start-Process -FilePath 'powershell.exe' -ArgumentList ($args -join ' ') -Verb RunAs -Wait + Write-Host 'Administrator installer window finished. You can close this window.' -ForegroundColor Green + exit 0 +} + +function Install-NodeWithWinget { + $winget = Resolve-CommandSource 'winget' + if (-not $winget) { return $false } + + Write-Step 'Installing Node.js LTS with winget' + & $winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -eq 0) { + Refresh-Path + return $true + } + + Write-Warn 'winget did not finish successfully; falling back to direct Node.js MSI.' + return $false +} + +function Install-NodeWithMsi { + Write-Step 'Installing Node.js LTS from nodejs.org' + $arch = 'win-x64-msi' + if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { + $arch = 'win-arm64-msi' + } elseif ($env:PROCESSOR_ARCHITECTURE -eq 'x86' -and -not $env:PROCESSOR_ARCHITEW6432) { + $arch = 'win-x86-msi' + } + + $index = Invoke-RestMethod -Uri 'https://nodejs.org/dist/index.json' + $release = $index | Where-Object { $_.lts -ne $false -and $_.files -contains $arch } | Select-Object -First 1 + if (-not $release) { throw "Could not find a Node.js LTS MSI for $arch" } + + $tempDir = Join-Path $env:TEMP 'ndc-ai-workspace-bridge' + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + $msi = Join-Path $tempDir "node-$($release.version)-$arch.msi" + $url = "https://nodejs.org/dist/$($release.version)/node-$($release.version)-$arch.msi" + Invoke-WebRequest -Uri $url -OutFile $msi + + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList "/i `"$msi`" /qn /norestart" -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Node.js MSI failed with code $($process.ExitCode)" + } + Refresh-Path +} + +function Ensure-Node { + Write-Step 'Checking Node.js' + Refresh-Path + if (Resolve-CommandSource 'node') { + Write-Ok "Node found: $(& node --version)" + } else { + if (-not (Install-NodeWithWinget)) { + Install-NodeWithMsi + } + } + + Refresh-Path + if (-not (Resolve-CommandSource 'node')) { throw 'Node.js was not found after installation.' } + if (-not (Get-NpmPath)) { throw 'npm was not found after Node.js installation.' } + Write-Ok "npm found: $(Get-NpmVersion)" +} + +function Ensure-Codex { + Write-Step 'Checking Codex CLI' + Refresh-Path + if (-not (Resolve-CommandSource 'codex')) { + Invoke-NpmInstallCodex + Refresh-Path + } + + $npmPrefix = Get-NpmGlobalPrefix + Add-UserPath $npmPrefix + Refresh-Path + + if (-not (Resolve-CommandSource 'codex')) { + $codexCmd = Join-Path $npmPrefix 'codex.cmd' + if (Test-Path $codexCmd) { + Add-UserPath $npmPrefix + } + } + + if (-not (Resolve-CommandSource 'codex')) { + throw 'Codex CLI was installed, but codex is not available in PATH.' + } + + try { + $version = (& codex --version) -join ' ' + Write-Ok "Codex found: $version" + } catch { + Write-Ok 'Codex found.' + } +} + +function Ensure-CodexLogin { + if ($NoCodexLogin) { + Write-Warn 'Codex login skipped by flag.' + return + } + + $authFile = Join-Path $env:USERPROFILE '.codex\auth.json' + if (Test-Path $authFile) { + Write-Ok 'Codex auth file found.' + return + } + + Write-Step 'Codex login' + Write-Host 'A browser login flow may open. Finish it, then return to this window.' + $loginOk = $false + try { + & codex login + if ($LASTEXITCODE -eq 0) { $loginOk = $true } + } catch { + $loginOk = $false + } + + if (-not $loginOk) { + try { + & codex --login + if ($LASTEXITCODE -eq 0) { $loginOk = $true } + } catch { + $loginOk = $false + } + } + + if (-not $loginOk -and -not (Test-Path $authFile)) { + Write-Warn 'Codex login did not complete. The bridge will start, but messages will fail until Codex is logged in.' + } +} + +function Remove-TomlComment { + param([string]$Line) + $text = [string]$Line + $inSingle = $false + $inDouble = $false + $escaped = $false + for ($i = 0; $i -lt $text.Length; $i++) { + $char = $text[$i] + if ($escaped) { + $escaped = $false + continue + } + if ($inDouble -and $char -eq '\') { + $escaped = $true + continue + } + if ((-not $inDouble) -and $char -eq "'") { + $inSingle = -not $inSingle + continue + } + if ((-not $inSingle) -and $char -eq '"') { + $inDouble = -not $inDouble + continue + } + if ((-not $inSingle) -and (-not $inDouble) -and $char -eq '#') { + return $text.Substring(0, $i) + } + } + return $text +} + +function Normalize-TomlKey { + param([string]$Line) + return ((Remove-TomlComment $Line) -replace '\s', '').Replace('"', '').Replace("'", '') +} + +function Normalize-TomlHeader { + param([string]$Line) + $text = (Remove-TomlComment $Line).Trim() + if (-not $text.StartsWith('[') -or -not $text.EndsWith(']')) { return '' } + return Normalize-TomlKey $text +} + +function Has-OpsAgentInlineTransport { + param([string]$Line) + $key = Normalize-TomlKey $Line + return ($key -match '^nodedc-ops-agent=\{.*transport=') +} + +function Repair-CodexMcpConfig { + $configPath = Join-Path $env:USERPROFILE '.codex\config.toml' + if (-not (Test-Path $configPath)) { return } + + try { + $raw = Get-Content -Path $configPath -Raw -Encoding UTF8 + } catch { + Write-Warn "Could not read Codex config: $($_.Exception.Message)" + return + } + + $next = $raw + $changed = $false + + $lines = $next -split '\r?\n' + $inMcpServers = $false + $inOpsAgent = $false + $headersChanged = $false + $requiredChanged = $false + $transportChanged = $false + $hasHttpHeaders = $false + $commentDuplicateHeadersSection = $false + foreach ($line in $lines) { + $headerKey = Normalize-TomlHeader $line + if ($headerKey -eq '[mcp_servers.nodedc-ops-agent.http_headers]') { + $hasHttpHeaders = $true + break + } + } + for ($i = 0; $i -lt $lines.Count; $i++) { + $headerKey = Normalize-TomlHeader $lines[$i] + if ($commentDuplicateHeadersSection) { + if ($headerKey.StartsWith('[')) { + $commentDuplicateHeadersSection = $false + } else { + if ($lines[$i].Trim() -and -not $lines[$i].TrimStart().StartsWith('#')) { + $lines[$i] = '# ' + $lines[$i] + $headersChanged = $true + } + continue + } + } + if ($headerKey -eq '[mcp_servers.nodedc-ops-agent.headers]') { + if (-not $hasHttpHeaders) { + $lines[$i] = '[mcp_servers.nodedc-ops-agent.http_headers]' + $headersChanged = $true + $hasHttpHeaders = $true + } else { + $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: http_headers already exists' + $headersChanged = $true + $commentDuplicateHeadersSection = $true + } + $inOpsAgent = $false + $inMcpServers = $false + continue + } + if ($headerKey -eq '[mcp_servers.nodedc-ops-agent]') { + $inOpsAgent = $true + $inMcpServers = $false + continue + } + if ($headerKey -eq '[mcp_servers]') { + $inMcpServers = $true + $inOpsAgent = $false + continue + } + if (($inOpsAgent -or $inMcpServers) -and $headerKey.StartsWith('[')) { + $inOpsAgent = $false + $inMcpServers = $false + } + $activeLine = Remove-TomlComment $lines[$i] + if ($inOpsAgent -and $activeLine -match '^(\s*required\s*=\s*)true(\s*)$') { + $lines[$i] = $Matches[1] + 'false' + $Matches[2] + $requiredChanged = $true + } + if ($inOpsAgent -and $activeLine -match '^\s*transport\s*=') { + $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: Codex infers transport from url' + $transportChanged = $true + } + if ($inMcpServers -and (Has-OpsAgentInlineTransport $activeLine)) { + $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: Codex rejects transport here' + $transportChanged = $true + } + } + if ($headersChanged -or $requiredChanged -or $transportChanged) { + $next = $lines -join ([Environment]::NewLine) + $changed = $true + if ($headersChanged) { + Write-Ok 'Codex MCP headers normalized for nodedc-ops-agent.' + } + if ($requiredChanged) { + Write-Ok 'Codex MCP nodedc-ops-agent marked optional for bridge sessions.' + } + if ($transportChanged) { + Write-Ok 'Codex MCP transport normalized for nodedc-ops-agent.' + } + } + + if (-not $changed) { return } + + try { + $backupPath = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + Copy-Item -Path $configPath -Destination $backupPath -Force + Write-Ok "Codex config backup created: $backupPath" + Set-Content -Path $configPath -Value $next -Encoding UTF8 + } catch { + Write-Warn "Could not update Codex config: $($_.Exception.Message)" + } +} + +function Get-WorkspacePath { + $workspacePath = Get-DefaultWorkspaceCandidate + Write-Ok "Codex workspace: $workspacePath" + return $workspacePath +} + +function Write-WorkerFiles { + param( + [string]$Root, + [string]$WorkspacePath + ) + + Write-Step 'Writing bridge worker' + $logs = Join-Path $Root 'logs' + New-Item -ItemType Directory -Force -Path $Root | Out-Null + New-Item -ItemType Directory -Force -Path $logs | Out-Null + if (-not (Test-Path $WorkspacePath)) { + New-Item -ItemType Directory -Force -Path $WorkspacePath | Out-Null + } + + $workerPath = Join-Path $Root 'worker.mjs' + $ndcAgentMcpServerPath = Join-Path $Root 'ndcAgentMcpServer.mjs' + $startScript = Join-Path $Root 'start-bridge.ps1' + $watchdogScript = Join-Path $Root 'watchdog-bridge.ps1' + $configPath = Join-Path $Root 'config.json' + $npmPrefix = Get-NpmGlobalPrefix + $nodeSource = Resolve-CommandSource 'node' + $nodeDir = [System.IO.Path]::GetDirectoryName($nodeSource) + $logPath = Join-Path $logs 'bridge.log' + $activeMirror = Join-Path $WorkspacePath '.nodedc\ai-workspace' + $resolvedMachineName = if ($MachineName) { $MachineName } else { $env:COMPUTERNAME } + $hubUrlCandidates = @(Get-HubUrlCandidates) + $hubUrlsText = ($hubUrlCandidates -join ',') + + $workerCode = @' +#!/usr/bin/env node +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs/promises' +import fssync from 'node:fs' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const HOST = process.env.AI_BRIDGE_HOST || '0.0.0.0' +const PORT = Number(process.env.AI_BRIDGE_PORT || 8787) +const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex') +const SESSION_INDEX = process.env.AI_BRIDGE_SESSION_INDEX || path.join(CODEX_HOME, 'session_index.jsonl') +const SESSIONS_DIR = process.env.AI_BRIDGE_SESSIONS_DIR || path.join(CODEX_HOME, 'sessions') +const CODEX_BIN = process.env.AI_BRIDGE_CODEX_BIN || 'codex' +const DEFAULT_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]' +const CODEX_ARGS = parseArgs(process.env.AI_BRIDGE_CODEX_ARGS || DEFAULT_CODEX_ARGS) +const CODEX_RESUME_ARGS = parseArgs(process.env.AI_BRIDGE_CODEX_RESUME_ARGS || '') +const CODEX_CWD = process.env.AI_BRIDGE_CODEX_CWD || process.cwd() +const MESSAGE_TIMEOUT_MS = Number(process.env.AI_BRIDGE_MESSAGE_TIMEOUT_MS || 12 * 60 * 60 * 1000) +const RESUME_TIMEOUT_MS = Number(process.env.AI_BRIDGE_RESUME_TIMEOUT_MS || MESSAGE_TIMEOUT_MS) +const MAX_BODY_BYTES = Number(process.env.AI_BRIDGE_MAX_BODY_BYTES || 1024 * 1024) +const MAX_STDIO_BYTES = Number(process.env.AI_BRIDGE_MAX_STDIO_BYTES || 2 * 1024 * 1024) +const MAX_CONVERSATIONS = Number(process.env.AI_BRIDGE_MAX_CONVERSATIONS || 100) +const MAX_SESSION_FILES = Number(process.env.AI_BRIDGE_MAX_SESSION_FILES || 5000) +const MAX_SESSION_FILE_BYTES = Number(process.env.AI_BRIDGE_MAX_SESSION_FILE_BYTES || 64 * 1024 * 1024) +const MAX_MESSAGES_PER_CONVERSATION = Number(process.env.AI_BRIDGE_MAX_MESSAGES_PER_CONVERSATION || 300) +const MAX_MESSAGE_CHARS = Number(process.env.AI_BRIDGE_MAX_MESSAGE_CHARS || 12000) +const MAX_WORKSPACES = Number(process.env.AI_BRIDGE_MAX_WORKSPACES || 120) +const REASONING_SUMMARY_DELTA_MIN_CHARS = Number(process.env.AI_BRIDGE_REASONING_SUMMARY_DELTA_MIN_CHARS || 80) +const REASONING_SUMMARY_DELTA_MAX_WAIT_MS = Number(process.env.AI_BRIDGE_REASONING_SUMMARY_DELTA_MAX_WAIT_MS || 1800) +const CODEX_STOP_GRACE_MS = Number(process.env.AI_BRIDGE_CODEX_STOP_GRACE_MS || 2500) +const STALE_RUNNING_SESSION_MS = Number(process.env.AI_BRIDGE_STALE_RUNNING_SESSION_MS || 10 * 60 * 1000) +const FINAL_MESSAGE_PHASES = new Set(['final', 'final_answer', 'answer']) +const BRIDGE_FILE = fileURLToPath(import.meta.url) +const BRIDGE_DIR = path.dirname(BRIDGE_FILE) +const NDC_AGENT_CODEX_HOME = path.resolve(process.env.AI_BRIDGE_NDC_AGENT_CODEX_HOME || path.join(BRIDGE_DIR, 'codex-home-ndc-agent-core')) +const DEFAULT_NDC_AGENT_MCP_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp' +const HUB_URL = String(process.env.AI_BRIDGE_HUB_URL || '').trim() +const HUB_URLS = parseHubUrls(process.env.AI_BRIDGE_HUB_URLS || '', HUB_URL) +const PAIRING_CODE = String(process.env.AI_BRIDGE_PAIRING_CODE || '').trim() +const MACHINE_NAME = String(process.env.AI_BRIDGE_MACHINE_NAME || os.hostname()).trim() +const HUB_RECONNECT_MS = Number(process.env.AI_BRIDGE_HUB_RECONNECT_MS || 5000) +const HUB_CONNECT_TIMEOUT_MS = Number(process.env.AI_BRIDGE_HUB_CONNECT_TIMEOUT_MS || 10000) +const hubState = { + mode: Boolean(HUB_URLS.length && PAIRING_CODE), + connected: false, + url: '', + pairingCode: PAIRING_CODE, + candidates: HUB_URLS, + lastConnectedAt: '', + lastDisconnectedAt: '', + lastError: '', +} +const ACTIVE_MIRROR_ENABLED = process.env.AI_BRIDGE_ACTIVE_MIRROR !== '0' +const ACTIVE_MIRROR_DIR = resolveWorkspacePath(process.env.AI_BRIDGE_ACTIVE_MIRROR_DIR || path.join('.nodedc', 'ai-workspace')) +const ACTIVE_MIRROR_CURRENT_FILE = path.join(ACTIVE_MIRROR_DIR, 'current.md') +const ACTIVE_MIRROR_EVENTS_FILE = path.join(ACTIVE_MIRROR_DIR, 'events.jsonl') +const ACTIVE_MIRROR_MAX_EVENTS = Number(process.env.AI_BRIDGE_ACTIVE_MIRROR_MAX_EVENTS || 80) +const ACTIVE_MIRROR_OPEN_IDE = process.env.AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE !== '0' +const ACTIVE_MIRROR_IDE_BIN = String(process.env.AI_BRIDGE_ACTIVE_MIRROR_IDE_BIN || 'code').trim() +const NDC_AGENT_MCP_SERVER = resolveWorkspacePath(defaultNdcAgentMcpServerPath()) +const NDC_AGENT_MCP_REF_PATH = process.env.AI_BRIDGE_NDC_AGENT_MCP_REF_PATH || + path.resolve(path.dirname(NDC_AGENT_MCP_SERVER), '..', '..', '..', 'tools', 'NDCMCP') +const activeCodexRunsByRequest = new Map() +const activeCodexRunsByThread = new Map() + +function defaultNdcAgentMcpServerPath() { + const explicit = String(process.env.AI_BRIDGE_NDC_AGENT_MCP_SERVER || '').trim() + if (explicit) return explicit + const installed = path.join(BRIDGE_DIR, 'ndcAgentMcpServer.mjs') + if (fssync.existsSync(installed)) return installed + const cwdLeaf = path.basename(String(CODEX_CWD || '').replace(/[\\/]+$/, '')) + if (cwdLeaf === 'nodedc-source') return path.join('server', 'aiWorkspace', 'ndcAgentMcpServer.mjs') + return path.join('nodedc-source', 'server', 'aiWorkspace', 'ndcAgentMcpServer.mjs') +} + +function resolveWorkspacePath(value) { + const text = String(value || '').trim() + if (!text) return CODEX_CWD + return path.isAbsolute(text) ? text : path.resolve(CODEX_CWD, text) +} + +async function resolveExistingDirectory(value, fallback = CODEX_CWD) { + const fallbackPath = resolveWorkspacePath(fallback) + const candidate = resolveWorkspacePath(value || fallback) + try { + const stat = await fs.stat(candidate) + if (stat.isDirectory()) return candidate + } catch {} + return fallbackPath +} + +function workspaceTitle(value) { + const normalized = String(value || '').replace(/[\\/]+$/, '') + return path.basename(normalized) || normalized || 'Workspace' +} + +function workspaceId(value) { + return String(value || '').trim() +} + +function parseHubUrls(raw, primary = '') { + const values = [] + const push = (value) => { + const text = String(value || '').trim() + if (!text) return + try { + const url = new URL(text) + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return + url.username = '' + url.password = '' + const clean = url.toString().replace(/\/+$/, '') + if (!values.includes(clean)) values.push(clean) + } catch {} + } + push(primary) + const text = String(raw || '').trim() + if (!text) return values + try { + const parsed = JSON.parse(text) + if (Array.isArray(parsed)) { + parsed.forEach(push) + return values + } + } catch {} + text.split(/[\n,;]+/).forEach(push) + return values +} + +function nowMs() { + return Date.now() +} + +function elapsedLabel(startedAt) { + const elapsed = Math.max(0, nowMs() - Number(startedAt || nowMs())) + if (elapsed < 1000) return `${elapsed}ms` + return `${(elapsed / 1000).toFixed(1)}s` +} + +function elapsedFromIso(value) { + const startedAt = Date.parse(String(value || '')) + if (!Number.isFinite(startedAt)) return '' + return elapsedLabel(startedAt) +} + +function cleanRunKey(value, limit = 160) { + return String(value || '').trim().slice(0, limit) +} + +function registerActiveCodexRun(control = {}, child, onEvent = () => {}) { + const requestId = cleanRunKey(control.requestId, 120) + const threadId = cleanRunKey(control.threadId, 160) + if (!requestId && !threadId) return null + const run = { + requestId, + threadId, + child, + onEvent, + closed: false, + stopRequested: false, + stopTimer: null, + stop() { + if (run.closed) return false + if (run.stopRequested) return true + run.stopRequested = true + try { + run.onEvent({ kind: 'stopped', message: 'Codex stop requested.' }) + } catch {} + try { + run.child.kill('SIGTERM') + } catch {} + run.stopTimer = setTimeout(() => { + if (run.closed) return + try { + run.child.kill('SIGKILL') + } catch {} + }, CODEX_STOP_GRACE_MS) + return true + }, + unregister() { + run.closed = true + if (run.stopTimer) clearTimeout(run.stopTimer) + if (requestId && activeCodexRunsByRequest.get(requestId) === run) activeCodexRunsByRequest.delete(requestId) + if (threadId && activeCodexRunsByThread.get(threadId) === run) activeCodexRunsByThread.delete(threadId) + }, + } + if (requestId) activeCodexRunsByRequest.set(requestId, run) + if (threadId) activeCodexRunsByThread.set(threadId, run) + return run +} + +function stopActiveCodexRun(payload = {}) { + const requestId = cleanRunKey(payload?.requestId, 120) + const threadId = cleanRunKey(payload?.threadId, 160) + const run = (requestId ? activeCodexRunsByRequest.get(requestId) : null) + || (threadId ? activeCodexRunsByThread.get(threadId) : null) + || null + if (!run) { + return { + ok: true, + stopped: false, + reason: 'active_codex_run_not_found', + requestId, + threadId, + } + } + const stopped = run.stop() + return { + ok: true, + stopped, + requestId: run.requestId, + threadId: run.threadId, + } +} + +function parseArgs(raw) { + const text = String(raw || '').trim() + if (!text) return [] + try { + const parsed = JSON.parse(text) + if (Array.isArray(parsed)) return parsed.map((item) => String(item)) + } catch {} + return text.split(/\s+/).filter(Boolean) +} + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload) + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': process.env.AI_BRIDGE_ALLOW_ORIGIN || '*', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + }) + res.end(body) +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let size = 0 + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + size += Buffer.byteLength(chunk) + if (size > MAX_BODY_BYTES) { + reject(new Error('body_too_large')) + req.destroy() + return + } + body += chunk + }) + req.on('end', () => { + if (!body.trim()) return resolve({}) + try { + resolve(JSON.parse(body)) + } catch { + reject(new Error('json_invalid')) + } + }) + req.on('error', reject) + }) +} + +async function readConversations() { + let raw = '' + try { + raw = await fs.readFile(SESSION_INDEX, 'utf8') + } catch { + return [] + } + const conversations = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + const item = JSON.parse(line) + const id = String(item.id || '').trim() + if (!id) return null + return { + id, + title: String(item.thread_name || item.title || id).trim(), + updatedAt: item.updated_at || item.updatedAt || null, + } + } catch { + return null + } + }) + .filter(Boolean) + .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))) + .slice(0, MAX_CONVERSATIONS) + + const sessionFiles = await listSessionFiles(SESSIONS_DIR) + await Promise.all(conversations.map(async (conversation) => { + const sessionFile = await findSessionFile(sessionFiles, conversation) + if (!sessionFile) { + conversation.messages = [] + return + } + conversation.sessionFile = sessionFile + const workspaceMeta = await readSessionWorkspaceMeta(sessionFile) + if (workspaceMeta?.cwd) { + conversation.workspacePath = workspaceMeta.cwd + conversation.workspaceTitle = workspaceTitle(workspaceMeta.cwd) + conversation.workspaceUpdatedAt = workspaceMeta.updatedAt || conversation.updatedAt || null + } + const sessionState = await readSessionState(sessionFile) + conversation.messages = sessionState.messages + conversation.status = sessionState.status + conversation.running = sessionState.running + conversation.sessionUpdatedAt = sessionState.updatedAt + })) + + return conversations +} + +function buildPrompt(payload) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + const userMessage = String(payload?.message || '').trim() + const history = payload?.resume ? [] : normalizeHistory(payload?.history || []) + const isNdcAgentCore = String(context.modeId || '').trim() === 'ndc-agent-core' + const ndcAgentMcpApiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context) + const lines = [ + 'You are connected to NODE.DC AI Workspace through AI Workspace Bridge.', + '', + 'Context:', + `- mode: ${context.modeTitle || context.modeId || 'unknown'}`, + `- workflow: ${context.workflowTitle || context.workflowId || 'unknown'}`, + `- agent node: ${context.agentNodeTitle || context.agentNodeId || 'unknown'}`, + `- role: ${context.roleTitle || context.roleId || 'unknown'}`, + `- workspace: ${context.workspacePath || payload?.workspacePath || CODEX_CWD}`, + '- use the user language for the final answer and public progress/reasoning summaries; if the user writes in Russian, write them in Russian', + '- while working, provide concise public progress/reasoning summaries when the Codex runtime exposes them', + '- public progress/reasoning summaries should say what you are checking, reading, comparing, or deciding', + '- keep public progress/reasoning summaries user-facing; do not include raw command lines, JSON, hidden chain-of-thought, or bridge transport details', + ...isNdcAgentCore ? [ + '', + 'NDC Agent Core mode contract:', + '- Treat the selected agent node as the only writable target.', + '- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.', + '- The Engine second-level workflow file is the source of truth for edits.', + `- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`, + `- Selected workflowId: ${context.workflowId || 'unknown'}`, + `- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`, + '- Read the second-level graph with GET /subworkflow?workflowId=&nodeId=.', + '- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.', + '- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow.', + '- Do not use direct n8n/NDC core MCP servers, local workflow files, or shell probes for graph edits in this mode.', + '- Use only ndc_agent_core MCP tools for the selected second-level workflow and stop after a cancellation, fetch, or policy error with the exact tool name and error.', + '- Supported operations: addNode, upsertNode, updateNode, removeNode, moveNode, addConnection, removeConnection.', + '- Prefer small patches and preserve existing node ids, node names, positions, parameters, and connections unless the user asks to change them.', + '- After saving through the Engine API, the open Engine canvas refreshes from dc.subworkflow.json; never edit local files or use shell fallbacks for graph changes in this mode.', + ] : [], + '', + ...history.length ? [ + 'Recent conversation from AI Workspace:', + ...history.map((message) => `${message.role}: ${message.text}`), + '', + ] : [], + 'User message:', + userMessage, + ] + return lines.join('\n') +} + +async function listSessionFiles(rootDir) { + const files = [] + async function walk(dir) { + if (files.length >= MAX_SESSION_FILES) return + let entries = [] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + entries.sort((a, b) => b.name.localeCompare(a.name)) + for (const entry of entries) { + if (files.length >= MAX_SESSION_FILES) return + const itemPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(itemPath) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(itemPath) + } + } + } + await walk(rootDir) + return files +} + +async function findSessionFile(files, conversation) { + const id = String(conversation?.id || '').trim() + if (!id) return '' + const updatedAt = String(conversation?.updatedAt || '').trim() + const dateMatch = updatedAt.match(/^(\d{4})-(\d{2})-(\d{2})/) + if (dateMatch) { + const dateDir = path.join(SESSIONS_DIR, dateMatch[1], dateMatch[2], dateMatch[3]) + try { + const entries = await fs.readdir(dateDir) + const match = entries.find((name) => name.endsWith('.jsonl') && name.includes(id)) + if (match) return path.join(dateDir, match) + } catch {} + } + return files.find((file) => path.basename(file).includes(id)) || '' +} + +async function readTextFileOrTail(filePath) { + const stat = await fs.stat(filePath) + if (stat.size <= MAX_SESSION_FILE_BYTES) return fs.readFile(filePath, 'utf8') + const handle = await fs.open(filePath, 'r') + try { + const size = Math.min(stat.size, MAX_SESSION_FILE_BYTES) + const buffer = Buffer.alloc(size) + await handle.read(buffer, 0, size, Math.max(0, stat.size - size)) + const text = buffer.toString('utf8') + return text.slice(text.indexOf('\n') + 1) + } finally { + await handle.close() + } +} + +async function readTextFileHead(filePath, maxBytes = 256 * 1024) { + const stat = await fs.stat(filePath) + const size = Math.min(stat.size, maxBytes) + if (size <= 0) return '' + const handle = await fs.open(filePath, 'r') + try { + const buffer = Buffer.alloc(size) + await handle.read(buffer, 0, size, 0) + return buffer.toString('utf8') + } finally { + await handle.close() + } +} + +async function readSessionWorkspaceMeta(filePath) { + let text = '' + try { + text = await readTextFileHead(filePath) + } catch { + return null + } + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue + try { + const item = JSON.parse(line) + const payload = item?.payload || {} + if (item?.type === 'session_meta' && payload?.cwd) { + return { + cwd: String(payload.cwd || '').trim(), + updatedAt: payload.timestamp || item.timestamp || null, + } + } + const contentText = readContentText(payload?.content) + const match = String(contentText || '').match(/([\s\S]*?)<\/cwd>/i) + if (match?.[1]) { + return { + cwd: match[1].trim(), + updatedAt: item.timestamp || null, + } + } + } catch {} + } + return null +} + +function timestampMs(value) { + const parsed = Date.parse(String(value || '')) + return Number.isFinite(parsed) ? parsed : 0 +} + +function messageTimestampMs(message) { + return timestampMs(message?.createdAt) +} + +function itemTimestampMs(item) { + return timestampMs(item?.timestamp || item?.createdAt || item?.created_at || item?.payload?.createdAt || item?.payload?.created_at) +} + +function isFinalAssistantMessage(message) { + if (message?.role !== 'assistant') return false + const phase = String(message.phase || '').trim().toLowerCase() + return phase ? FINAL_MESSAGE_PHASES.has(phase) : true +} + +async function readSessionState(filePath) { + let text = '' + let updatedAt = null + try { + const stat = await fs.stat(filePath) + updatedAt = stat.mtime?.toISOString?.() || null + text = await readTextFileOrTail(filePath) + } catch { + return { messages: [], status: 'unknown', running: false, updatedAt } + } + + const responseMessages = [] + const eventMessages = [] + let latestUserAt = 0 + let latestAssistantFinalAt = 0 + let latestActivityAt = 0 + let latestFailureAt = 0 + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue + let item = null + try { + item = JSON.parse(line) + } catch { + continue + } + const responseMessage = extractResponseMessage(item) + if (responseMessage) { + responseMessages.push(responseMessage) + const at = messageTimestampMs(responseMessage) + latestActivityAt = Math.max(latestActivityAt, at) + if (responseMessage.role === 'user') latestUserAt = Math.max(latestUserAt, at) + if (isFinalAssistantMessage(responseMessage)) latestAssistantFinalAt = Math.max(latestAssistantFinalAt, at) + continue + } + const eventMessage = extractEventMessage(item) + if (eventMessage) { + eventMessages.push(eventMessage) + const at = messageTimestampMs(eventMessage) + latestActivityAt = Math.max(latestActivityAt, at) + if (eventMessage.role === 'user') latestUserAt = Math.max(latestUserAt, at) + if (isFinalAssistantMessage(eventMessage)) latestAssistantFinalAt = Math.max(latestAssistantFinalAt, at) + } + const typeText = `${item?.type || ''} ${item?.payload?.type || ''}`.toLowerCase() + if (/error|failed|interrupted|aborted/.test(typeText)) latestFailureAt = Math.max(latestFailureAt, itemTimestampMs(item)) + } + + const latestResponseAt = responseMessages.reduce((max, message) => Math.max(max, messageTimestampMs(message)), 0) + const liveEventMessages = responseMessages.length + ? eventMessages.filter((message) => messageTimestampMs(message) > latestResponseAt) + : eventMessages + const messages = dedupeMessages([...responseMessages, ...liveEventMessages] + .sort((a, b) => messageTimestampMs(a) - messageTimestampMs(b))) + .slice(-MAX_MESSAGES_PER_CONVERSATION) + const failed = latestFailureAt > latestUserAt + const rawRunning = latestUserAt > Math.max(latestAssistantFinalAt, latestFailureAt) + const updatedAtMs = timestampMs(updatedAt) + const staleRunning = rawRunning && Number.isFinite(updatedAtMs) && Date.now() - updatedAtMs > STALE_RUNNING_SESSION_MS + const running = rawRunning && !staleRunning + const status = running ? 'running' : failed ? 'failed' : latestActivityAt ? 'completed' : 'unknown' + return { messages, status, running, updatedAt } +} + +async function readSessionMessages(filePath) { + const state = await readSessionState(filePath) + return state.messages +} + +function extractResponseMessage(item) { + const payload = item?.payload || {} + if (item?.type !== 'response_item' || payload?.type !== 'message') return null + const role = normalizeRole(payload.role) + if (!role) return null + const text = normalizeDisplayMessageText(role, readContentText(payload.content ?? payload.text ?? payload.message)) + if (!text || shouldSkipConversationText(text)) return null + return { + id: String(payload.id || item.id || `${role}-${Date.now()}-${Math.random()}`), + role, + text, + phase: String(payload.phase || item.phase || '').trim(), + createdAt: payload.created_at || payload.createdAt || item.created_at || item.createdAt || item.timestamp || null, + } +} + +function extractEventMessage(item) { + const payload = item?.payload || {} + if (item?.type !== 'event_msg') return null + const type = String(payload.type || '') + const role = type === 'user_message' ? 'user' : type === 'agent_message' ? 'assistant' : '' + if (!role) return null + const text = normalizeDisplayMessageText(role, payload.message || payload.text || '') + if (!text || shouldSkipConversationText(text)) return null + return { + id: String(payload.id || item.id || `${role}-${Date.now()}-${Math.random()}`), + role, + text, + phase: String(payload.phase || item.phase || '').trim(), + createdAt: payload.created_at || payload.createdAt || item.created_at || item.createdAt || item.timestamp || null, + } +} + +function readContentText(content) { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content + .map((part) => { + if (typeof part === 'string') return part + if (typeof part?.text === 'string') return part.text + if (typeof part?.content === 'string') return part.content + return '' + }) + .filter(Boolean) + .join('\n') +} + +function normalizeRole(role) { + const value = String(role || '').trim().toLowerCase() + if (value === 'user') return 'user' + if (value === 'assistant') return 'assistant' + return '' +} + +function normalizeMessageText(value) { + const text = String(value || '').replace(/\r\n/g, '\n').trim() + if (!text) return '' + return text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS).trimEnd()}\n\n[truncated]` : text +} + +function normalizeDisplayMessageText(role, value) { + const text = normalizeMessageText(value) + if (role !== 'user') return text + const clean = text.replace(/^user\s*\n/i, '').trim() + if (!clean.includes('You are connected to NODE.DC AI Workspace through AI Workspace Bridge.')) return text + const match = clean.match(/(?:^|\n)User message:\s*\n?([\s\S]*)$/i) + return normalizeMessageText(match?.[1] || '') +} + +function shouldSkipConversationText(text) { + return /^\s*<(environment_context|permissions instructions|app-context|collaboration_mode|apps_instructions|skills_instructions|plugins_instructions|turn_aborted)\b/i.test(text) +} + +function dedupeMessages(messages) { + const result = [] + for (const message of messages) { + const previous = result[result.length - 1] + if (previous && previous.role === message.role && previous.text === message.text) continue + result.push(message) + } + return result +} + +function normalizeHistory(history) { + if (!Array.isArray(history)) return [] + return history + .slice(-40) + .map((message) => ({ + role: normalizeRole(message?.role), + text: normalizeMessageText(message?.text || message?.content || ''), + })) + .filter((message) => message.role && message.text) +} + +function cleanSessionId(value) { + const text = String(value || '').trim() + if (!text || text.startsWith('ai-') || text.includes(':')) return '' + return /^[A-Za-z0-9._-]{8,160}$/.test(text) ? text : '' +} + +function buildResumeArgs(sessionId) { + const clean = cleanSessionId(sessionId) + if (!clean) return CODEX_ARGS + if (CODEX_RESUME_ARGS.length) { + return CODEX_RESUME_ARGS.map((item) => item.replace(/\{sessionId\}/g, clean)) + } + const stdinIndex = CODEX_ARGS.lastIndexOf('-') + const base = stdinIndex >= 0 + ? CODEX_ARGS.filter((_, index) => index !== stdinIndex) + : [...CODEX_ARGS] + if (base[0] !== 'exec') return CODEX_ARGS + return [...base, 'resume', clean, '-'] +} + +function isNdcAgentCorePayload(payload) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + return String(context.modeId || '').trim() === 'ndc-agent-core' +} + +function insertBeforePromptStdin(args, extras) { + const base = Array.isArray(args) ? [...args] : [] + const stdinIndex = base.lastIndexOf('-') + if (stdinIndex < 0) return [...base, ...extras] + return [...base.slice(0, stdinIndex), ...extras, ...base.slice(stdinIndex)] +} + +function buildNdcAgentMcpContext(payload, cwd) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + const apiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context) + return { + workflowId: String(context.workflowId || '').trim(), + workflowTitle: String(context.workflowTitle || '').trim(), + agentNodeId: String(context.agentNodeId || '').trim(), + agentNodeTitle: String(context.agentNodeTitle || '').trim(), + roleId: String(context.roleId || '').trim(), + roleTitle: String(context.roleTitle || '').trim(), + ndcAgentMcpApiBaseUrl: apiBaseUrl.replace(/\/+$/, ''), + schemaVersion: 'v2.3.2', + n8nMcpRefPath: NDC_AGENT_MCP_REF_PATH, + workspacePath: cwd, + } +} + +function deriveNdcAgentMcpApiBaseUrl(context) { + const explicit = cleanApiBaseUrl(context?.ndcAgentMcpApiBaseUrl || context?.engineApiBaseUrl || '') + const hubDerived = deriveNdcAgentMcpApiBaseUrlFromHub() + if (!explicit) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE + if (!isHttpUrl(explicit)) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE + if (hubDerived && isProtectedNodedcEngineUrl(explicit)) return hubDerived + if (isLoopbackUrl(explicit) && hubDerived) return hubDerived + if (!explicit.endsWith('/api/ndc-agent-mcp')) return `${explicit.replace(/\/+$/, '')}/api/ndc-agent-mcp` + return explicit +} + +function cleanApiBaseUrl(value) { + const text = String(value || '').trim().replace(/\/+$/, '') + return text || '' +} + +function isLoopbackUrl(value) { + try { + const url = new URL(value) + const host = url.hostname.toLowerCase() + return host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.') + } catch { + return false + } +} + +function isHttpUrl(value) { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +function deriveNdcAgentMcpApiBaseUrlFromHub() { + return deriveNdcAgentMcpBaseFromHubUrl(hubState.url) + || HUB_URLS.map(deriveNdcAgentMcpBaseFromHubUrl).find(Boolean) + || '' +} + +function deriveNdcAgentMcpBaseFromHubUrl(value) { + try { + const url = new URL(String(value || '').trim()) + const hostname = url.hostname.toLowerCase() + if (!hostname) return '' + if (url.protocol === 'wss:') url.protocol = 'https:' + else if (url.protocol === 'ws:') url.protocol = 'http:' + else if (url.protocol !== 'http:' && url.protocol !== 'https:') return '' + url.pathname = '' + url.search = '' + url.hash = '' + const origin = url.toString().replace(/\/+$/, '') + if (hostname === 'ai-hub.nodedc.ru') { + const pairingCode = cleanHubPairingCode(PAIRING_CODE) + return pairingCode ? `${origin}/api/ai-workspace/hub/v1/ndc-agent-mcp/${pairingCode}` : '' + } + return `${origin}/api/ndc-agent-mcp` + } catch { + return '' + } +} + +function cleanHubPairingCode(value) { + return String(value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32) +} + +function isProtectedNodedcEngineUrl(value) { + try { + const url = new URL(String(value || '').trim()) + return url.hostname.toLowerCase() === 'engine.nodedc.ru' + } catch { + return false + } +} + +function tomlString(value) { + return JSON.stringify(String(value || '')) +} + +function stripTomlSection(raw, sectionName) { + const section = String(sectionName || '').trim() + if (!section) return String(raw || '') + const lines = String(raw || '').split(/\r?\n/) + const result = [] + let skipping = false + for (const line of lines) { + const header = normalizedTomlHeader(line) + if (header) { + skipping = header === `[${section}]` || header.startsWith(`[${section}.`) + } + if (!skipping) result.push(line) + } + return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() +} + +function stripTopLevelTomlKeys(raw, keyNames) { + const keys = new Set((Array.isArray(keyNames) ? keyNames : []).map((key) => String(key || '').trim()).filter(Boolean)) + if (!keys.size) return String(raw || '') + const lines = String(raw || '').split(/\r?\n/) + const result = [] + let inTable = false + for (const line of lines) { + if (normalizedTomlHeader(line)) { + inTable = true + result.push(line) + continue + } + if (!inTable) { + const match = stripTomlComment(line).match(/^\s*([A-Za-z0-9_-]+)\s*=/) + if (match && keys.has(match[1])) continue + } + result.push(line) + } + return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() +} + +async function copyIfExists(from, to) { + try { + await fs.copyFile(from, to) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function ndcAgentMcpEnvConfig(mcpContext, cwd) { + return [ + '[mcp_servers.ndc_agent_core.env]', + `NDC_AGENT_MCP_ROOT = ${tomlString(CODEX_CWD)}`, + `NDC_AGENT_MCP_CONTEXT = ${tomlString(JSON.stringify(mcpContext || {}))}`, + `NDC_AGENT_MCP_API_BASE_URL = ${tomlString(mcpContext?.ndcAgentMcpApiBaseUrl || '')}`, + `AI_BRIDGE_HUB_URL = ${tomlString(hubState.url || HUB_URL)}`, + `AI_BRIDGE_HUB_URLS = ${tomlString(HUB_URLS.join(','))}`, + `AI_BRIDGE_PAIRING_CODE = ${tomlString(PAIRING_CODE)}`, + `AI_BRIDGE_CODEX_CWD = ${tomlString(cwd || CODEX_CWD)}`, + ].join('\n') +} + +async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) { + await fs.mkdir(NDC_AGENT_CODEX_HOME, { recursive: true }) + await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(NDC_AGENT_CODEX_HOME, 'auth.json')) + const runtimeConfig = [ + 'approval_policy = "never"', + 'sandbox_mode = "danger-full-access"', + ].join('\n') + const ndcConfig = [ + '[mcp_servers.ndc_agent_core]', + `command = ${tomlString('node')}`, + `args = [${tomlString(NDC_AGENT_MCP_SERVER)}]`, + 'startup_timeout_sec = 20', + 'tool_timeout_sec = 120', + ].join('\n') + await fs.writeFile(path.join(NDC_AGENT_CODEX_HOME, 'config.toml'), `${runtimeConfig}\n\n${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}\n`, 'utf8') + return NDC_AGENT_CODEX_HOME +} + +async function codexInvocationForPayload(baseArgs, payload, cwd) { + if (!isNdcAgentCorePayload(payload)) return { args: baseArgs, env: {} } + const mcpContext = buildNdcAgentMcpContext(payload, cwd) + const codexHome = await prepareNdcAgentCodexHome(mcpContext, cwd) + return { + args: baseArgs, + env: { + CODEX_HOME: codexHome, + NDC_AGENT_MCP_ROOT: CODEX_CWD, + NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext), + NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl, + AI_BRIDGE_PAIRING_CODE: PAIRING_CODE, + }, + } +} + +function isResumeRecoverableError(error) { + const text = String(error?.message || error || '') + return /unknown command|unrecognized|unexpected argument|invalid subcommand|usage:|failed to refresh available models|timeout waiting for child process to exit|codex_timeout|session.*not.*found|no such session|could not find.*session|session.*does not exist|no rollout found|thread\/resume.*failed|error resuming thread/i.test(text) +} + +function usesJsonOutput(args) { + return Array.isArray(args) && args.includes('--json') +} + +function truncateText(value, max = 1400) { + const text = String(value || '').trim() + return text.length > max ? `${text.slice(0, max).trim()}...` : text +} + +function markdownText(value, max = 12000) { + return truncateText(String(value || '').replace(/\r\n/g, '\n'), max) +} + +function sanitizeCodexErrorText(value, max = 1400) { + const text = String(value || '').replace(/\r\n/g, '\n').trim() + if (!text) return '' + const statusMatch = text.match(/\b(?:HTTP error:\s*)?((?:401|403|429|5\d\d)\s+[A-Za-z][A-Za-z\s-]*)\b/i) + const urlMatch = text.match(/\burl:\s*((?:https?|wss):\/\/[^\s,]+)/i) + || text.match(/\b((?:https?|wss):\/\/chatgpt\.com\/[^\s,]+)/i) + const rayMatch = text.match(/\bcf-ray:\s*([A-Za-z0-9-]+)/i) + || text.match(/\bRay ID:?\s*([A-Za-z0-9-]+)/i) + if (/chatgpt\.com\/backend-api\/codex|Unable to load site|cf-ray|Cloudflare|403 Forbidden/i.test(text)) { + return [ + `Codex backend rejected request${statusMatch ? `: ${statusMatch[1].trim()}` : ''}.`, + urlMatch ? `url: ${urlMatch[1].replace(/[)>]+$/, '')}` : '', + rayMatch ? `cf-ray: ${rayMatch[1]}` : '', + ].filter(Boolean).join(' ') + } + return truncateText(text, max) +} + +function eventText(event) { + return [ + event?.message, + event?.text, + event?.command ? `$ ${event.command}` : '', + ].map((item) => String(item || '').trim()).filter(Boolean).join('\n') +} + +function renderMirrorSignal(label, value) { + const text = markdownText(value, 1800) + return text ? `- ${label}: ${text.replace(/\n/g, '\n ')}` : '' +} + +function renderActiveMirrorMarkdown(state) { + const activity = state.events.length + ? state.events.map((event) => { + const text = markdownText(event.text, 2200) + const inline = text.includes('\n') ? `\n\n${text}\n` : ` ${text}` + return `- ${event.at} ${event.kind}:${inline}` + }).join('\n') + : '- waiting for Codex events' + const signal = [ + renderMirrorSignal('Reasoning', state.currentReasoning), + renderMirrorSignal('Command', state.currentCommand), + renderMirrorSignal('Files', state.currentFiles), + renderMirrorSignal('Tool', state.currentTool), + renderMirrorSignal('Todo', state.currentTodo), + ].filter(Boolean).join('\n') || '- waiting for first Codex signal' + return [ + '# NDC AI Workspace Active Run', + '', + `Status: ${state.status}`, + `Machine: ${state.machineName}`, + `Workspace: ${state.cwd}`, + `Mode: ${state.modeTitle || state.modeId || 'unknown'}`, + `Thread: ${state.threadTitle || state.threadId || 'unknown'}`, + `Request: ${state.requestId}`, + `Started: ${state.startedAt}`, + `Updated: ${state.updatedAt}`, + state.finishedAt ? `Finished: ${state.finishedAt}` : '', + '', + '## Active Mirror', + '', + `Current file: ${state.currentFile}`, + `Events file: ${state.eventsFile}`, + '', + '## User Message', + '', + markdownText(state.userMessage || ''), + '', + '## Current Signal', + '', + signal, + '', + '## Live Activity', + '', + activity, + '', + '## Last Assistant Output', + '', + markdownText(state.assistantText || 'No assistant output yet.'), + '', + ].filter((line) => line !== '').join('\n') +} + +function openActiveMirrorInIde(filePath) { + if (!ACTIVE_MIRROR_OPEN_IDE || !ACTIVE_MIRROR_IDE_BIN || !filePath) return + try { + const child = spawn(ACTIVE_MIRROR_IDE_BIN, ['-r', filePath], { + detached: true, + shell: process.platform === 'win32', + stdio: 'ignore', + windowsHide: true, + }) + child.unref() + } catch {} +} + +function activeMirrorPathsForCwd(cwd) { + const workspace = resolveWorkspacePath(cwd || CODEX_CWD) + const mirrorDir = workspace === CODEX_CWD + ? ACTIVE_MIRROR_DIR + : path.join(workspace, '.nodedc', 'ai-workspace') + return { + mirrorDir, + currentFile: path.join(mirrorDir, 'current.md'), + eventsFile: path.join(mirrorDir, 'events.jsonl'), + } +} + +async function hasGitMarker(dir) { + try { + await fs.stat(path.join(dir, '.git')) + return true + } catch { + return false + } +} + +async function addWorkspace(map, workspacePath, source, patch = {}) { + const cleanPath = String(workspacePath || '').trim() + if (!cleanPath) return + let stat = null + try { + stat = await fs.stat(cleanPath) + } catch { + return + } + if (!stat.isDirectory()) return + const id = workspaceId(cleanPath) + const previous = map.get(id) || {} + map.set(id, { + id, + path: cleanPath, + title: patch.title || previous.title || workspaceTitle(cleanPath), + kind: patch.kind || previous.kind || (await hasGitMarker(cleanPath) ? 'git' : 'folder'), + source: Array.from(new Set([...(previous.source ? String(previous.source).split(', ') : []), source].filter(Boolean))).join(', '), + lastUsedAt: patch.lastUsedAt || previous.lastUsedAt || null, + sessionCount: Number(previous.sessionCount || 0) + Number(patch.sessionCount || 0), + }) +} + +async function discoverWorkspaces() { + const map = new Map() + await addWorkspace(map, CODEX_CWD, 'configured', { title: `${workspaceTitle(CODEX_CWD)} (default)` }) + const conversations = await readConversations() + for (const conversation of conversations) { + if (!conversation.workspacePath) continue + await addWorkspace(map, conversation.workspacePath, 'codex-session', { + lastUsedAt: conversation.workspaceUpdatedAt || conversation.updatedAt || null, + sessionCount: 1, + }) + } + const workspaces = Array.from(map.values()) + .sort((a, b) => { + if (a.path === CODEX_CWD) return -1 + if (b.path === CODEX_CWD) return 1 + const byDate = String(b.lastUsedAt || '').localeCompare(String(a.lastUsedAt || '')) + if (byDate) return byDate + return String(a.title || '').localeCompare(String(b.title || '')) + }) + .slice(0, MAX_WORKSPACES) + return { + ok: true, + cwd: CODEX_CWD, + workspaces, + } +} + +function createActiveMirror(command, payload = {}, meta = {}) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null + const cwd = String(meta.cwd || CODEX_CWD) + const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd) + const startedAt = new Date().toISOString() + const state = { + requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`), + threadId: String(payload.threadId || ''), + threadTitle: String(payload.threadTitle || ''), + userMessage: String(payload.message || ''), + modeId: String(context.modeId || ''), + modeTitle: String(context.modeTitle || ''), + machineName: MACHINE_NAME, + cwd, + status: 'running', + startedAt, + updatedAt: startedAt, + finishedAt: '', + assistantText: '', + currentFile, + eventsFile, + currentReasoning: '', + currentCommand: '', + currentFiles: '', + currentTool: '', + currentTodo: '', + events: [], + ideOpened: false, + } + let chain = Promise.resolve() + + const write = (row) => { + chain = chain.then(async () => { + state.updatedAt = new Date().toISOString() + await fs.mkdir(mirrorDir, { recursive: true }) + if (row) { + await fs.appendFile(eventsFile, `${JSON.stringify({ + requestId: state.requestId, + threadId: state.threadId, + threadTitle: state.threadTitle, + machineName: state.machineName, + cwd: state.cwd, + ...row, + })}\n`, 'utf8') + } + await fs.writeFile(currentFile, renderActiveMirrorMarkdown(state), 'utf8') + if (!state.ideOpened) { + state.ideOpened = true + openActiveMirrorInIde(currentFile) + } + }).catch(() => {}) + return chain + } + + const record = (event = {}) => { + const kind = String(event.kind || event.type || 'event') + const text = eventText(event) + const row = { + at: new Date().toISOString(), + kind, + text: markdownText(text, 6000), + } + if (event.currentFile) row.currentFile = String(event.currentFile) + if (event.eventsFile) row.eventsFile = String(event.eventsFile) + if (kind === 'codex_reasoning') state.currentReasoning = markdownText(event.text || text, 6000) + if (kind === 'codex_command' || kind === 'start') state.currentCommand = markdownText(event.command || text, 3000) + if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000) + if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000) + if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000) + if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, 12000) + if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed' + if (kind === 'timeout') state.status = 'timeout' + if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed' + state.events.push(row) + state.events = state.events.slice(-ACTIVE_MIRROR_MAX_EVENTS) + write(row) + } + + return { + currentFile, + eventsFile, + record, + async complete(status, assistantText = '') { + state.status = status + state.finishedAt = new Date().toISOString() + if (assistantText) state.assistantText = markdownText(assistantText, 12000) + await write({ + at: state.finishedAt, + kind: `mirror_${status}`, + text: status, + }) + }, + } +} + +function textFromUnknown(value) { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join('\n') + if (value && typeof value === 'object') { + return [ + value.text, + value.summary, + value.content, + value.message, + value.delta, + ].map(textFromUnknown).filter(Boolean).join('\n') + } + return '' +} + +function reasoningTextFromItem(item) { + return textFromUnknown([ + item?.summary, + item?.summary_text, + item?.text, + item?.content, + ]) +} + +function reasoningSummaryEventKey(event) { + const itemId = String(event?.item_id || event?.item?.id || event?.response_id || 'reasoning').trim() || 'reasoning' + const summaryIndex = String(event?.summary_index ?? event?.content_index ?? event?.output_index ?? 0) + return `${itemId}:${summaryIndex}` +} + +function reasoningSummaryTextFromEvent(event) { + return textFromUnknown([ + event?.text, + event?.delta, + event?.part, + event?.summary, + event?.summary_text, + ]) +} + +function shouldEmitReasoningSummaryDelta(record, pending) { + const text = String(pending || '').trim() + if (!text) return false + if (/[.!?。!?]\s*$/.test(text)) return true + if (text.length >= REASONING_SUMMARY_DELTA_MIN_CHARS) return true + return nowMs() - Number(record.lastEmittedAt || 0) >= REASONING_SUMMARY_DELTA_MAX_WAIT_MS && text.length >= 32 +} + +function updateReasoningSummaryState(state, event, mode) { + if (!state) return null + const key = reasoningSummaryEventKey(event) + const record = state.get(key) || { text: '', emittedLength: 0, lastEmittedAt: 0 } + if (mode === 'delta') { + const delta = textFromUnknown(event?.delta) + if (!delta) return null + record.text += delta + const pending = record.text.slice(record.emittedLength).trim() + if (!shouldEmitReasoningSummaryDelta(record, pending)) { + state.set(key, record) + return null + } + record.emittedLength = record.text.length + record.lastEmittedAt = nowMs() + state.set(key, record) + return pending + } + + const text = reasoningSummaryTextFromEvent(event) + if (text) record.text = text + const pending = record.text.slice(record.emittedLength).trim() || (!record.emittedLength ? record.text.trim() : '') + record.emittedLength = record.text.length + record.lastEmittedAt = nowMs() + state.set(key, record) + return pending +} + +function createCodexJsonEventMapper() { + const reasoningSummaryState = new Map() + return (event) => codexJsonEventToBridgeEvent(event, reasoningSummaryState) +} + +function summarizeFileChanges(changes) { + const list = Array.isArray(changes) ? changes : [] + if (!list.length) return 'File changes reported.' + return list + .slice(0, 12) + .map((change) => `${String(change.kind || 'update')}: ${String(change.path || '')}`.trim()) + .join('\n') +} + +function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) { + const type = String(event?.type || '') + const item = event?.item && typeof event.item === 'object' ? event.item : null + const itemType = String(item?.type || '') + if (type === 'response.reasoning_summary_text.delta') { + const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'delta')) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_summary_text.done') { + const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'done')) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_summary_part.done' || type === 'response.reasoning_summary_part.added') { + const text = truncateText(reasoningSummaryTextFromEvent(event)) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_text.delta' || type === 'response.reasoning_text.done') return null + if (type === 'thread.started') { + const sessionId = String(event.thread_id || '').trim() + return { kind: 'codex_thread', sessionId, message: `Thread started: ${sessionId}`.trim() } + } + if (type === 'turn.started') return { kind: 'codex_turn', message: 'Turn started.' } + if (type === 'turn.completed') { + const usage = event.usage || {} + const tokens = Number(usage.input_tokens || 0) + Number(usage.output_tokens || 0) + return { kind: 'codex_turn_completed', message: tokens ? `Turn completed. Tokens: ${tokens}.` : 'Turn completed.' } + } + if (type === 'turn.failed') return { kind: 'error', message: event?.error?.message || 'Turn failed.' } + if (type === 'error') return { kind: 'error', message: event.message || 'Codex stream error.' } + if (!item) return null + + const prefix = type === 'item.started' ? 'Started' : type === 'item.updated' ? 'Updated' : 'Completed' + if (itemType === 'reasoning') { + const text = truncateText(reasoningTextFromItem(item)) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: truncateText(item.text || '') } + if (itemType === 'command_execution') { + const status = item.status ? ` - ${item.status}` : '' + return { + kind: 'codex_command', + command: item.command || '', + message: `${prefix} command${status}: ${item.command || ''}`.trim(), + text: truncateText(item.aggregated_output || ''), + } + } + if (itemType === 'file_change') { + return { + kind: 'codex_files', + message: `${prefix} file change${item.status ? ` - ${item.status}` : ''}.`, + text: summarizeFileChanges(item.changes), + } + } + if (itemType === 'mcp_tool_call') { + return { + kind: 'codex_tool', + message: `${prefix} MCP ${item.server || ''}.${item.tool || ''}${item.status ? ` - ${item.status}` : ''}`.trim(), + text: truncateText(item.error?.message || ''), + } + } + if (itemType === 'todo_list') { + const todos = Array.isArray(item.items) + ? item.items.map((todo) => `${todo.completed ? '[x]' : '[ ]'} ${todo.text || ''}`).join('\n') + : '' + return { kind: 'codex_todo', message: `${prefix} todo list.`, text: truncateText(todos) } + } + if (itemType === 'web_search') return { kind: 'codex_tool', message: `${prefix} web search: ${item.query || ''}`.trim() } + if (itemType === 'error') return { kind: 'error', message: item.message || 'Codex item error.' } + return { kind: 'codex_event', message: `${prefix} ${itemType || type}.` } +} + +function finalMessageFromCodexEvent(event) { + const item = event?.item && typeof event.item === 'object' ? event.item : null + if (!item || item.type !== 'agent_message') return '' + return String(item.text || '').trim() +} + +async function runCodexForPayload(prompt, payload, onEvent = () => {}, cwd = CODEX_CWD, meta = {}) { + const baseArgs = payload?.resume ? buildResumeArgs(payload?.threadId) : CODEX_ARGS + const invocation = await codexInvocationForPayload(baseArgs, payload, cwd) + if (isNdcAgentCorePayload(payload)) { + try { + const context = buildNdcAgentMcpContext(payload, cwd) + onEvent({ + kind: 'ndc_agent_mcp_context', + message: `NDC Agent MCP API base: ${context.ndcAgentMcpApiBaseUrl}`, + }) + } catch {} + } + const control = { + requestId: meta?.requestId, + threadId: payload?.threadId, + } + if (baseArgs === CODEX_ARGS) { + return runCodex(prompt, invocation.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, invocation.env) + } + try { + return await runCodex(prompt, invocation.args, onEvent, RESUME_TIMEOUT_MS, cwd, control, invocation.env) + } catch (error) { + if (!isResumeRecoverableError(error)) throw error + onEvent({ kind: 'fallback', message: 'Codex resume failed; falling back to a new exec run.' }) + const fallback = await codexInvocationForPayload(CODEX_ARGS, payload, cwd) + return runCodex(prompt, fallback.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, fallback.env) + } +} + +function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MESSAGE_TIMEOUT_MS, cwd = CODEX_CWD, control = {}, envOverrides = {}) { + return new Promise((resolve, reject) => { + const startedAt = nowMs() + let firstOutputSeen = false + const jsonOutput = usesJsonOutput(args) + let jsonBuffer = '' + let finalJsonMessage = '' + let plainStdout = '' + const mapCodexJsonEvent = createCodexJsonEventMapper() + onEvent({ + kind: 'start', + command: `${CODEX_BIN} ${args.join(' ')}`, + cwd, + message: 'Codex process started.', + }) + const child = spawn(CODEX_BIN, args, { + cwd, + env: { ...process.env, ...(envOverrides || {}) }, + shell: process.platform === 'win32', + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) + const activeRun = registerActiveCodexRun(control, child, onEvent) + let stdout = '' + let stderr = '' + let killed = false + const timeout = setTimeout(() => { + killed = true + onEvent({ kind: 'timeout', message: 'Codex process timed out.' }) + child.kill('SIGTERM') + }, timeoutMs) + + child.stdout.on('data', (chunk) => { + const text = String(chunk) + if (!firstOutputSeen) { + firstOutputSeen = true + onEvent({ kind: 'first_output', message: `First Codex output after ${elapsedLabel(startedAt)}.` }) + } + stdout += text + if (jsonOutput) { + jsonBuffer += text + const lines = jsonBuffer.split(/\r?\n/) + jsonBuffer = lines.pop() || '' + lines.forEach((line) => { + const trimmed = line.trim() + if (!trimmed) return + try { + const event = JSON.parse(trimmed) + const finalMessage = finalMessageFromCodexEvent(event) + if (finalMessage) finalJsonMessage = finalMessage + const bridgeEvent = mapCodexJsonEvent(event) + if (bridgeEvent) onEvent(bridgeEvent) + } catch { + plainStdout += `${line}\n` + onEvent({ kind: 'stdout', stream: 'stdout', text: line }) + } + }) + } else { + plainStdout += text + onEvent({ kind: 'stdout', stream: 'stdout', text }) + } + if (Buffer.byteLength(stdout) > MAX_STDIO_BYTES) child.kill('SIGTERM') + }) + child.stderr.on('data', (chunk) => { + const text = String(chunk) + if (!firstOutputSeen) { + firstOutputSeen = true + onEvent({ kind: 'first_output', message: `First Codex output after ${elapsedLabel(startedAt)}.` }) + } + stderr += text + onEvent({ kind: 'stderr', stream: 'stderr', text: sanitizeCodexErrorText(text) }) + if (Buffer.byteLength(stderr) > MAX_STDIO_BYTES) child.kill('SIGTERM') + }) + child.on('error', (error) => { + clearTimeout(timeout) + activeRun?.unregister() + onEvent({ kind: 'error', message: String(error?.message || error || 'codex_error') }) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(timeout) + activeRun?.unregister() + if (activeRun?.stopRequested) { + onEvent({ kind: 'stopped', message: 'Codex process stopped.' }) + return resolve('') + } + const stderrMessage = sanitizeCodexErrorText(stderr.trim()) + if (killed) return reject(new Error(`codex_timeout${stderrMessage ? `: ${stderrMessage.slice(0, 1000)}` : ''}`)) + if (code !== 0) { + onEvent({ kind: 'error', message: stderrMessage || `codex_exit_${code}` }) + return reject(new Error(stderrMessage || `codex_exit_${code}`)) + } + onEvent({ kind: 'done', message: `Codex process completed in ${elapsedLabel(startedAt)}.` }) + resolve(finalJsonMessage || plainStdout.trim() || stdout.trim() || stderr.trim()) + }) + child.stdin.end(prompt) + }) +} + +function stripTomlComment(line) { + const text = String(line || '') + let inSingle = false + let inDouble = false + let escaped = false + for (let i = 0; i < text.length; i += 1) { + const char = text[i] + if (escaped) { + escaped = false + continue + } + if (inDouble && char === '\\') { + escaped = true + continue + } + if (!inDouble && char === "'") { + inSingle = !inSingle + continue + } + if (!inSingle && char === '"') { + inDouble = !inDouble + continue + } + if (!inSingle && !inDouble && char === '#') { + return text.slice(0, i) + } + } + return text +} + +function normalizedTomlKey(line) { + return stripTomlComment(line).replace(/\s+/g, '').replace(/["']/g, '') +} + +function normalizedTomlHeader(line) { + const text = stripTomlComment(line).trim() + if (!text.startsWith('[') || !text.endsWith(']')) return '' + return normalizedTomlKey(text) +} + +function hasOpsAgentInlineTransport(line) { + const text = normalizedTomlKey(line) + return /^nodedc-ops-agent=\{.*(?:^|[,}])?transport=/.test(text) +} + +async function inspectCodexRuntime() { + const configPath = path.join(CODEX_HOME, 'config.toml') + const authPath = path.join(CODEX_HOME, 'auth.json') + try { + await fs.access(authPath) + } catch { + return { + ok: false, + error: 'codex_auth_missing', + configPath, + authPath, + } + } + try { + const raw = await fs.readFile(configPath, 'utf8') + let inMcpServers = false + let inOpsAgent = false + for (const line of raw.split(/\r?\n/)) { + const header = normalizedTomlHeader(line) + if (header) { + if (header === '[mcp_servers.nodedc-ops-agent.headers]') { + return { + ok: false, + error: 'invalid_codex_mcp_headers:nodedc-ops-agent', + configPath, + authPath, + } + } + inMcpServers = header === '[mcp_servers]' + inOpsAgent = header === '[mcp_servers.nodedc-ops-agent]' + continue + } + const activeLine = stripTomlComment(line) + if ( + (inOpsAgent && /^\s*transport\s*=/.test(activeLine)) || + (inMcpServers && hasOpsAgentInlineTransport(activeLine)) + ) { + return { + ok: false, + error: 'invalid_codex_mcp_transport:nodedc-ops-agent', + configPath, + authPath, + } + } + } + } catch (error) { + if (error?.code !== 'ENOENT') { + return { + ok: false, + error: `codex_config_read_failed:${String(error?.message || error)}`, + configPath, + authPath, + } + } + } + return { ok: true, error: '', configPath, authPath } +} + +async function handleBridgeCommand(command, payload = {}, onEvent = () => {}, meta = {}) { + if (command === 'health') { + const runtime = await inspectCodexRuntime() + return { + ok: runtime.ok, + error: runtime.error, + service: 'ai-workspace-bridge', + host: os.hostname(), + machineName: MACHINE_NAME, + runtimeStatus: runtime.ok ? 'ready' : 'error', + runtimeError: runtime.error, + runtimeCheckedAt: new Date().toISOString(), + codexHome: CODEX_HOME, + codexAuthPath: runtime.authPath, + codexBin: CODEX_BIN, + codexArgs: CODEX_ARGS, + cwd: CODEX_CWD, + hubMode: hubState.mode, + hubConnected: hubState.connected, + hub: { ...hubState }, + activeMirror: { + enabled: ACTIVE_MIRROR_ENABLED, + currentFile: ACTIVE_MIRROR_CURRENT_FILE, + eventsFile: ACTIVE_MIRROR_EVENTS_FILE, + }, + } + } + + if (command === 'conversations') { + const conversations = await readConversations() + return { ok: true, conversations } + } + + if (command === 'workspaces') { + return discoverWorkspaces() + } + + if (command === 'stop') { + return stopActiveCodexRun(payload) + } + + if (command === 'message') { + const message = String(payload?.message || '').trim() + if (!message) { + const error = new Error('message_empty') + error.status = 400 + throw error + } + const runtime = await inspectCodexRuntime() + if (!runtime.ok) { + const error = new Error(runtime.error || 'codex_runtime_not_ready') + error.status = 503 + throw error + } + const cwd = await resolveExistingDirectory(payload?.workspacePath || payload?.context?.workspacePath || CODEX_CWD) + const messagePayload = { + ...payload, + workspacePath: cwd, + context: { + ...(payload?.context && typeof payload.context === 'object' ? payload.context : {}), + workspacePath: cwd, + workspaceTitle: workspaceTitle(cwd), + }, + } + const mirror = createActiveMirror(command, messagePayload, { ...meta, cwd }) + const emitEvent = (event) => { + mirror?.record(event) + onEvent(event) + } + if (mirror) { + emitEvent({ + kind: 'active_mirror', + message: `Active mirror ready: ${mirror.currentFile}`, + currentFile: mirror.currentFile, + eventsFile: mirror.eventsFile, + cwd, + }) + } + try { + const reply = await runCodexForPayload(buildPrompt(messagePayload), messagePayload, emitEvent, cwd, meta) + await mirror?.complete('completed', reply) + return { + ok: true, + threadId: String(payload.threadId || ''), + workspacePath: cwd, + workspaceTitle: workspaceTitle(cwd), + activeMirror: mirror ? { + currentFile: mirror.currentFile, + eventsFile: mirror.eventsFile, + } : null, + message: { + role: 'assistant', + text: reply, + createdAt: new Date().toISOString(), + }, + } + } catch (error) { + await mirror?.complete('failed', String(error?.message || error || 'codex_failed')) + throw error + } + } + + const error = new Error('command_unknown') + error.status = 404 + throw error +} + +async function dataToText(data) { + if (typeof data === 'string') return data + if (Buffer.isBuffer(data)) return data.toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + if (data?.arrayBuffer) return Buffer.from(await data.arrayBuffer()).toString('utf8') + return String(data || '') +} + +function buildHubSocketUrl(hubUrl) { + const socketUrl = new URL(hubUrl) + socketUrl.searchParams.set('pairingCode', PAIRING_CODE) + socketUrl.searchParams.set('machineName', MACHINE_NAME) + return socketUrl.toString() +} + +function attachHubSocket(socket, hubUrl) { + console.log(`[ai-workspace-bridge] hub connected: ${hubUrl}`) + hubState.connected = true + hubState.url = hubUrl + hubState.lastConnectedAt = new Date().toISOString() + hubState.lastError = '' + void inspectCodexRuntime().then((runtime) => { + try { + socket.send(JSON.stringify({ + type: 'hello', + machineName: MACHINE_NAME, + host: os.hostname(), + cwd: CODEX_CWD, + runtimeStatus: runtime.ok ? 'ready' : 'error', + runtimeError: runtime.error, + runtimeCheckedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + })) + } catch {} + }).catch((error) => { + try { + socket.send(JSON.stringify({ + type: 'hello', + machineName: MACHINE_NAME, + host: os.hostname(), + cwd: CODEX_CWD, + runtimeStatus: 'error', + runtimeError: `codex_runtime_check_failed:${String(error?.message || error)}`, + runtimeCheckedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + })) + } catch {} + }) + + socket.onmessage = async (event) => { + let request = null + try { + request = JSON.parse(await dataToText(event.data)) + } catch { + return + } + if (request?.type !== 'request') return + const requestId = String(request.requestId || '') + const emitEvent = (payload) => { + try { + socket.send(JSON.stringify({ + type: 'event', + requestId, + event: { + ...(payload || {}), + at: new Date().toISOString(), + }, + })) + } catch {} + } + try { + const receivedAfter = elapsedFromIso(request?.payload?.client?.sentAt) + emitEvent({ + kind: 'bridge_received', + message: receivedAfter + ? `Bridge received request after ${receivedAfter}.` + : 'Bridge received request.', + }) + const payload = await handleBridgeCommand(String(request.command || ''), request.payload || {}, emitEvent, { requestId }) + socket.send(JSON.stringify({ type: 'response', requestId, ok: true, payload })) + } catch (error) { + emitEvent({ kind: 'error', message: String(error?.message || error || 'bridge_agent_failed') }) + socket.send(JSON.stringify({ + type: 'response', + requestId, + ok: false, + status: Number(error?.status || 502), + error: String(error?.message || error || 'bridge_agent_failed'), + })) + } + } + socket.onerror = (event) => { + const message = String(event?.message || 'websocket_error') + hubState.lastError = message + console.error(`[ai-workspace-bridge] hub error: ${message} (${hubUrl})`) + } + socket.onclose = () => { + hubState.connected = false + hubState.lastDisconnectedAt = new Date().toISOString() + hubState.lastError = 'hub_disconnected' + console.error(`[ai-workspace-bridge] hub disconnected; reconnecting in ${HUB_RECONNECT_MS}ms (${hubUrl})`) + setTimeout(() => connectHub(), HUB_RECONNECT_MS) + } +} + +function connectHub() { + if (!HUB_URLS.length || !PAIRING_CODE) return + if (typeof WebSocket === 'undefined') { + hubState.lastError = 'websocket_runtime_unavailable' + console.error('[ai-workspace-bridge] WebSocket is not available in this Node.js runtime; use Node.js 22+ for hub mode.') + return + } + + const attempts = [] + let reconnectScheduled = false + let selected = false + const scheduleReconnect = (delayMs, message) => { + if (reconnectScheduled) return + reconnectScheduled = true + if (message) { + hubState.lastError = message + console.error(message) + } + for (const attempt of attempts) { + try { attempt.socket.close() } catch {} + } + setTimeout(() => connectHub(), delayMs) + } + + const openTimer = setTimeout(() => { + if (selected) return + scheduleReconnect(1000, `[ai-workspace-bridge] hub candidates unavailable after ${HUB_CONNECT_TIMEOUT_MS}ms; retrying.`) + }, HUB_CONNECT_TIMEOUT_MS) + + for (const hubUrl of HUB_URLS) { + let socketUrl = '' + try { + socketUrl = buildHubSocketUrl(hubUrl) + } catch (error) { + const message = `invalid_hub_url:${String(error?.message || error)}` + hubState.lastError = message + console.error(`[ai-workspace-bridge] invalid hub URL: ${String(error?.message || error)} (${hubUrl})`) + continue + } + + const socket = new WebSocket(socketUrl) + const attempt = { socket, hubUrl, closed: false } + attempts.push(attempt) + socket.onopen = () => { + if (selected) { + try { socket.close() } catch {} + return + } + selected = true + clearTimeout(openTimer) + for (const other of attempts) { + if (other.socket !== socket) { + try { other.socket.close() } catch {} + } + } + attachHubSocket(socket, hubUrl) + } + socket.onerror = (event) => { + if (!selected) { + const message = String(event?.message || 'websocket_error') + hubState.lastError = message + console.error(`[ai-workspace-bridge] hub error: ${message} (${hubUrl})`) + } + } + socket.onclose = () => { + attempt.closed = true + if (selected || reconnectScheduled) return + if (attempts.length && attempts.every((item) => item.closed)) { + clearTimeout(openTimer) + scheduleReconnect(1000, '[ai-workspace-bridge] all hub candidates closed before ready; retrying.') + } + } + } + + if (!attempts.length) { + clearTimeout(openTimer) + scheduleReconnect(5000, '[ai-workspace-bridge] no valid hub candidates; retrying.') + } +} + +async function handle(req, res) { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`) + if (req.method === 'OPTIONS') return sendJson(res, 204, {}) + + if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/health') { + return sendJson(res, 200, await handleBridgeCommand('health')) + } + + if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/conversations') { + return sendJson(res, 200, await handleBridgeCommand('conversations')) + } + + if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/workspaces') { + return sendJson(res, 200, await handleBridgeCommand('workspaces')) + } + + if (req.method === 'POST' && url.pathname === '/api/ai-workspace/bridge/v1/message') { + const payload = await readBody(req) + try { + return sendJson(res, 200, await handleBridgeCommand('message', payload, () => {}, { requestId: `local-${Date.now()}` })) + } catch (error) { + return sendJson(res, Number(error?.status || 502), { ok: false, error: String(error?.message || error || 'codex_failed') }) + } + } + + if (req.method === 'POST' && url.pathname === '/api/ai-workspace/bridge/v1/stop') { + const payload = await readBody(req) + return sendJson(res, 200, await handleBridgeCommand('stop', payload)) + } + + return sendJson(res, 404, { ok: false, error: 'not_found' }) +} + +const server = http.createServer((req, res) => { + handle(req, res).catch((error) => { + sendJson(res, 500, { ok: false, error: String(error?.message || error || 'worker_failed') }) + }) +}) + +server.listen(PORT, HOST, () => { + console.log(`[ai-workspace-bridge] http://${HOST}:${PORT}`) + console.log(`[ai-workspace-bridge] codex: ${CODEX_BIN} ${CODEX_ARGS.join(' ')}`) + console.log(`[ai-workspace-bridge] codex home: ${CODEX_HOME}`) + console.log(`[ai-workspace-bridge] cwd: ${CODEX_CWD}`) + if (HUB_URLS.length && PAIRING_CODE) console.log(`[ai-workspace-bridge] hub candidates: ${HUB_URLS.join(', ')}`) +}) + +connectHub() +'@ + + $ndcAgentMcpServerCode = @' +#!/usr/bin/env node +import fs from 'node:fs/promises' +import path from 'node:path' + +const ROOT = process.env.NDC_AGENT_MCP_ROOT || process.cwd() +const DEFAULT_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp' +const DEFAULT_SCHEMA_VERSION = 'v2.3.2' +const ENGINE_FETCH_TIMEOUT_MS = Number(process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || 8000) +const context = parseContext() +const nodeCatalogCache = new Map() +let transportMode = null +let inputBuffer = Buffer.alloc(0) + +const FALLBACK_NODE_CATALOG = [ + { + name: 'webhook', + displayName: 'Webhook', + description: 'Starts a workflow from an HTTP webhook request.', + group: ['trigger'], + version: [2.1], + defaults: { name: 'Webhook' }, + inputs: [], + outputs: ['main'], + properties: [ + { name: 'httpMethod', displayName: 'HTTP Method', type: 'options', default: 'POST' }, + { name: 'path', displayName: 'Path', type: 'string', default: '' }, + { name: 'responseMode', displayName: 'Response Mode', type: 'options', default: 'onReceived' }, + ], + }, + { + name: 'set', + displayName: 'Set', + description: 'Sets or edits item fields.', + group: ['transform'], + version: [3.4], + defaults: { name: 'Set' }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { name: 'assignments', displayName: 'Assignments', type: 'fixedCollection', default: {} }, + { name: 'options', displayName: 'Options', type: 'collection', default: {} }, + ], + }, + { + name: 'code', + displayName: 'Code', + description: 'Runs custom JavaScript code.', + group: ['transform'], + version: [2], + defaults: { name: 'Code' }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { name: 'mode', displayName: 'Mode', type: 'options', default: 'runOnceForAllItems' }, + { name: 'jsCode', displayName: 'JavaScript Code', type: 'string', default: 'return items;' }, + ], + }, + { + name: 'httpRequest', + displayName: 'HTTP Request', + description: 'Makes an HTTP request and returns the response.', + group: ['input'], + version: [4.2], + defaults: { name: 'HTTP Request' }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { name: 'method', displayName: 'Method', type: 'options', default: 'GET' }, + { name: 'url', displayName: 'URL', type: 'string', default: '' }, + { name: 'sendHeaders', displayName: 'Send Headers', type: 'boolean', default: false }, + { name: 'sendBody', displayName: 'Send Body', type: 'boolean', default: false }, + ], + }, + { + name: 'respondToWebhook', + displayName: 'Respond to Webhook', + description: 'Returns a response to a Webhook trigger.', + group: ['output'], + version: [1.1], + defaults: { name: 'Respond to Webhook' }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { name: 'respondWith', displayName: 'Respond With', type: 'options', default: 'json' }, + { name: 'responseBody', displayName: 'Response Body', type: 'json', default: '{}' }, + ], + }, + { + name: 'noOp', + displayName: 'No Operation, do nothing', + description: 'Passes data through without changing it.', + group: ['transform'], + version: [1], + defaults: { name: 'No Operation, do nothing' }, + inputs: ['main'], + outputs: ['main'], + properties: [], + }, +] + +function parseContext() { + const raw = String(process.env.NDC_AGENT_MCP_CONTEXT || '').trim() + let parsed = {} + if (raw) { + try { parsed = JSON.parse(raw) || {} } catch {} + } + return { + modeId: 'ndc-agent-core', + workflowId: cleanString(parsed.workflowId || process.env.NDC_AGENT_MCP_WORKFLOW_ID || ''), + workflowTitle: cleanString(parsed.workflowTitle || process.env.NDC_AGENT_MCP_WORKFLOW_TITLE || ''), + agentNodeId: cleanString(parsed.agentNodeId || process.env.NDC_AGENT_MCP_NODE_ID || ''), + agentNodeTitle: cleanString(parsed.agentNodeTitle || process.env.NDC_AGENT_MCP_NODE_TITLE || ''), + roleId: cleanString(parsed.roleId || process.env.NDC_AGENT_MCP_ROLE_ID || ''), + roleTitle: cleanString(parsed.roleTitle || process.env.NDC_AGENT_MCP_ROLE_TITLE || ''), + apiBaseUrl: cleanApiBase(parsed.ndcAgentMcpApiBaseUrl || process.env.NDC_AGENT_MCP_API_BASE_URL || DEFAULT_API_BASE), + schemaVersion: cleanString(parsed.schemaVersion || process.env.NDC_AGENT_MCP_SCHEMA_VERSION || DEFAULT_SCHEMA_VERSION), + n8nMcpRefPath: cleanString(parsed.n8nMcpRefPath || process.env.NDC_AGENT_MCP_REF_PATH || path.resolve(ROOT, '..', 'tools', 'NDCMCP')), + } +} + +function cleanString(value, max = 1000) { + return String(value || '').trim().slice(0, max) +} + +function cleanApiBase(value) { + const raw = cleanString(value || DEFAULT_API_BASE) + return raw.replace(/\/+$/, '') || DEFAULT_API_BASE +} + +function uniqueStrings(values) { + const result = [] + const seen = new Set() + for (const value of values || []) { + const clean = cleanString(value, 1000).replace(/\/+$/, '') + if (!clean || seen.has(clean)) continue + seen.add(clean) + result.push(clean) + } + return result +} + +function isLoopbackUrl(value) { + try { + const host = new URL(value).hostname.toLowerCase() + return host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.') + } catch { + return false + } +} + +function hubOriginToApiBase(value) { + try { + const url = new URL(cleanString(value, 1000)) + const host = url.hostname.toLowerCase() + if (!host) return '' + if (url.protocol === 'wss:') url.protocol = 'https:' + else if (url.protocol === 'ws:') url.protocol = 'http:' + else if (url.protocol !== 'http:' && url.protocol !== 'https:') return '' + url.pathname = '' + url.search = '' + url.hash = '' + const origin = url.toString().replace(/\/+$/, '') + if (host === 'ai-hub.nodedc.ru') { + const pairingCode = cleanPairingCode(process.env.AI_BRIDGE_PAIRING_CODE) + return pairingCode ? `${origin}/api/ai-workspace/hub/v1/ndc-agent-mcp/${pairingCode}` : '' + } + return `${origin}/api/ndc-agent-mcp` + } catch { + return '' + } +} + +function cleanPairingCode(value) { + return cleanString(value, 100).toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32) +} + +function splitList(value) { + return cleanString(value, 4000).split(/[,;\r\n]+/).map((item) => item.trim()).filter(Boolean) +} + +function engineApiBaseCandidates() { + const explicit = cleanApiBase(context.apiBaseUrl) + const hubBases = [ + hubOriginToApiBase(process.env.AI_BRIDGE_HUB_URL), + ...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase), + ] + const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '') + const ordered = isLoopbackUrl(explicit) && hubBases.some(Boolean) + ? [...hubBases, envBase, explicit, DEFAULT_API_BASE] + : [explicit, envBase, ...hubBases, DEFAULT_API_BASE] + return uniqueStrings(ordered) +} + +function engineApiUrls(pathname) { + const pathValue = String(pathname || '') + return engineApiBaseCandidates().map((baseValue) => { + try { + const base = new URL(baseValue) + if (pathValue.startsWith('/api/')) return `${base.origin}${pathValue}` + if (pathValue.startsWith('/')) return `${baseValue}${pathValue}` + return new URL(pathValue, `${baseValue}/`).toString() + } catch { + return '' + } + }).filter(Boolean) +} + +function fetchFailureText(error) { + const parts = [ + error?.name, + error?.message, + error?.cause?.code, + error?.cause?.message, + ].map((item) => cleanString(item, 300)).filter(Boolean) + return parts.join(':') || 'fetch_failed' +} + +function toolText(value) { + const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + return { content: [{ type: 'text', text }], structuredContent: typeof value === 'string' ? undefined : value } +} + +function jsonRpcResult(id, result) { + return { jsonrpc: '2.0', id, result } +} + +function jsonRpcError(id, code, message, data) { + return { jsonrpc: '2.0', id, error: { code, message: String(message || 'error'), ...(data ? { data } : {}) } } +} + +function writeMessage(message) { + const json = JSON.stringify(message) + if (transportMode === 'content-length') { + const body = Buffer.from(json, 'utf8') + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`) + process.stdout.write(body) + return + } + process.stdout.write(`${json}\n`) +} + +async function readJson(file, fallback = null) { + try { return JSON.parse(await fs.readFile(file, 'utf8')) } catch { return fallback } +} + +async function loadNodeCatalog(version = context.schemaVersion) { + const key = cleanString(version || DEFAULT_SCHEMA_VERSION) + if (nodeCatalogCache.has(key)) return nodeCatalogCache.get(key) + const remoteCatalog = await loadRemoteNodeCatalog(key) + if (remoteCatalog.length) { + nodeCatalogCache.set(key, remoteCatalog) + return remoteCatalog + } + const direct = path.resolve(ROOT, 'server', 'assets', 'n8n', 'schema', key, 'nodes.catalog.json') + const fallback = path.resolve(ROOT, 'server', 'assets', 'n8n', 'schema', DEFAULT_SCHEMA_VERSION, 'nodes.catalog.json') + const raw = await readJson(direct, null) || await readJson(fallback, []) + const catalog = Array.isArray(raw) && raw.length ? raw : FALLBACK_NODE_CATALOG + nodeCatalogCache.set(key, catalog) + return catalog +} + +async function loadN8nMcpVersion() { + const pkg = await readJson(path.join(context.n8nMcpRefPath, 'package.json'), null) + return pkg?.version ? String(pkg.version) : '2.33.2' +} + +function engineApiUrl(pathname) { + return engineApiUrls(pathname)[0] || '' +} + +async function loadRemoteNodeCatalog(version) { + const query = new URLSearchParams({ kind: 'nodes', version: version || DEFAULT_SCHEMA_VERSION }) + const urls = engineApiUrls(`/api/n8n/schema?${query.toString()}`) + for (const url of urls) { + try { + const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS) }) + if (!res.ok) continue + const json = await res.json() + if (Array.isArray(json?.nodes)) return json.nodes + } catch {} + } + return [] +} + +function normalizeTypeName(value) { + const raw = cleanString(value, 240) + if (!raw) return '' + if (raw.startsWith('n8n-nodes-base.')) return raw.slice('n8n-nodes-base.'.length) + return raw +} + +function fullNodeType(value) { + const raw = cleanString(value, 240) + if (!raw) return '' + return raw.startsWith('n8n-nodes-base.') ? raw : `n8n-nodes-base.${raw}` +} + +function cleanWebhookSegment(value, fallback) { + const out = cleanString(value, 240) + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + return out || fallback +} + +function makeNdcWebhookPath(workflowId, nodeId) { + return `ndc/${cleanWebhookSegment(workflowId, 'workflow')}/${cleanWebhookSegment(nodeId, 'agent')}/run` +} + +function defaultSubworkflowGraph(workflowId = context.workflowId, nodeId = context.agentNodeId) { + return { + version: 1, + nodes: [{ + id: 'webhook-trigger', + type: 'n8nOp', + position: { x: 0, y: 0 }, + data: { + title: 'Webhook Trigger', + n8n: { + parameters: { + httpMethod: 'POST', + path: makeNdcWebhookPath(workflowId, nodeId), + responseMode: 'onReceived', + options: {}, + }, + type: 'n8n-nodes-base.webhook', + typeVersion: 2.1, + position: [0, 0], + id: 'webhook-trigger', + name: 'Webhook Trigger', + }, + w: 220, + h: 120, + n8nTypeLabel: 'Webhook', + n8nShowId: false, + n8nOutputLabels: [], + }, + }], + edges: [], + source: { + createdBy: 'ndc-agent-mcp', + initialized: 'default-webhook', + }, + } +} + +function normalizeGraphResult(value, workflowId = context.workflowId, nodeId = context.agentNodeId) { + const graph = value && typeof value === 'object' ? { ...value } : defaultSubworkflowGraph(workflowId, nodeId) + graph.version = Number(graph.version || 1) || 1 + graph.nodes = Array.isArray(graph.nodes) && graph.nodes.length ? graph.nodes : defaultSubworkflowGraph(workflowId, nodeId).nodes + graph.edges = Array.isArray(graph.edges) ? graph.edges : [] + return graph +} + +function versionSummary(version) { + if (Array.isArray(version)) return version + if (version === undefined || version === null) return [] + return [version] +} + +function compactNodeDefinition(node, maxProperties = 80) { + const properties = Array.isArray(node?.properties) ? node.properties.slice(0, maxProperties) : [] + return { + name: node?.name || '', + fullType: fullNodeType(node?.name || ''), + displayName: node?.displayName || node?.name || '', + description: node?.description || '', + group: node?.group || [], + versions: versionSummary(node?.version), + defaults: node?.defaults || {}, + inputs: node?.inputs || [], + outputs: node?.outputs || [], + credentials: node?.credentials || [], + properties, + propertiesTruncated: Array.isArray(node?.properties) && node.properties.length > properties.length, + } +} + +async function engineFetch(pathname, options = {}) { + const urls = engineApiUrls(pathname) + const failures = [] + for (const url of urls) { + let res = null + try { + res = await fetch(url, { + ...options, + redirect: 'manual', + signal: options.signal || AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS), + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers || {}), + }, + }) + } catch (error) { + failures.push(`${url} -> ${fetchFailureText(error)}`) + continue + } + const text = await res.text().catch(() => '') + let json = null + try { json = text ? JSON.parse(text) : null } catch {} + if (!res.ok || json?.ok === false) { + const error = new Error(json?.message || json?.error || `engine_http_${res.status}`) + error.status = res.status + error.payload = { ...(json && typeof json === 'object' ? json : {}), url } + throw error + } + if (!json || typeof json !== 'object') { + const error = new Error(`engine_non_json_response:${res.status}`) + error.status = 502 + error.payload = { + url, + status: res.status, + contentType: res.headers.get('content-type') || '', + preview: cleanString(text, 200), + } + throw error + } + return json + } + const error = new Error(`engine_fetch_failed:${failures.join(' | ') || 'no_engine_api_urls'}`) + error.payload = { attempts: failures, apiBaseCandidates: engineApiBaseCandidates(), pathname } + throw error +} + +function targetFromArgs(args = {}) { + const workflowId = cleanString(args.workflowId || context.workflowId) + const nodeId = cleanString(args.nodeId || args.agentNodeId || context.agentNodeId) + if (!workflowId || !nodeId) throw new Error('workflowId_and_nodeId_required') + return { workflowId, nodeId } +} + +async function handleGetContext() { + const n8nMcpVersion = await loadN8nMcpVersion() + return { + ok: true, + ...context, + apiBaseCandidates: engineApiBaseCandidates(), + n8nMcpVersion, + contract: { + sourceOfTruth: 'Engine second-level dc.subworkflow.json', + writeApi: `${context.apiBaseUrl}/subworkflow/patch`, + schemaApi: engineApiUrl(`/api/n8n/schema?kind=nodes&version=${encodeURIComponent(context.schemaVersion)}`), + directCoreWritesAllowed: false, + directFileWritesAllowed: false, + }, + } +} + +async function handleGetSubworkflow(args) { + const target = targetFromArgs(args) + const q = new URLSearchParams(target) + try { + const result = await engineFetch(`/subworkflow?${q.toString()}`) + return { + ok: true, + ...(result && typeof result === 'object' ? result : {}), + graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId), + } + } catch (error) { + if (Number(error?.status || 0) !== 404 && !/not_found/.test(String(error?.message || ''))) throw error + return { + ok: true, + graph: normalizeGraphResult(null, target.workflowId, target.nodeId), + missing: true, + } + } +} + +async function handleApplyPatch(args) { + const target = targetFromArgs(args) + const operations = Array.isArray(args.operations) ? args.operations : [] + if (!operations.length) throw new Error('operations_required') + const result = await engineFetch('/subworkflow/patch', { + method: 'POST', + body: JSON.stringify({ + workflowId: target.workflowId, + nodeId: target.nodeId, + intent: cleanString(args.intent || 'NDC Agent MCP patch', 500), + expectedUpdatedAt: cleanString(args.expectedUpdatedAt || ''), + operations, + }), + }) + if (result?.graph) { + return { ...result, graph: normalizeGraphResult(result.graph, target.workflowId, target.nodeId) } + } + const loaded = await handleGetSubworkflow(target) + return { + ok: true, + ...(result && typeof result === 'object' ? result : {}), + graph: loaded.graph, + recoveredAfterEmptyPatchResponse: !result?.graph, + } +} + +async function handleSearchNodes(args) { + const query = cleanString(args.query || args.q || '', 160).toLowerCase() + const limit = Math.min(Math.max(Number(args.limit || 12) || 12, 1), 50) + const catalog = await loadNodeCatalog(args.schemaVersion) + const terms = query.split(/\s+/).filter(Boolean) + const matches = catalog + .map((node) => { + const name = String(node?.name || '').toLowerCase() + const displayName = String(node?.displayName || '').toLowerCase() + const description = String(node?.description || '').toLowerCase() + const groups = (Array.isArray(node?.group) ? node.group : []).map((item) => String(item || '').toLowerCase()) + const score = !terms.length ? 1 : terms.reduce((acc, term) => { + if (name === term) return acc + 100 + if (displayName === term) return acc + 90 + if (name.includes(term)) return acc + 25 + if (displayName.includes(term)) return acc + 20 + if (groups.some((group) => group.includes(term))) return acc + 6 + if (description.includes(term)) return acc + 2 + return acc + }, 0) + return { node, score } + }) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || String(a.node?.displayName || '').localeCompare(String(b.node?.displayName || ''))) + .slice(0, limit) + .map(({ node }) => ({ + name: node?.name || '', + fullType: fullNodeType(node?.name || ''), + displayName: node?.displayName || node?.name || '', + description: node?.description || '', + group: node?.group || [], + versions: versionSummary(node?.version), + })) + return { ok: true, query, count: matches.length, nodes: matches } +} + +async function handleGetNodeDefinition(args) { + const nodeType = normalizeTypeName(args.nodeType || args.type || args.name) + if (!nodeType) throw new Error('nodeType_required') + const catalog = await loadNodeCatalog(args.schemaVersion) + const node = catalog.find((item) => ( + String(item?.name || '') === nodeType || + fullNodeType(item?.name || '') === cleanString(args.nodeType || args.type || args.name) + )) + if (!node) throw new Error(`node_not_found:${nodeType}`) + return { ok: true, node: compactNodeDefinition(node, Number(args.maxProperties || 80) || 80) } +} + +async function handleValidateSubworkflow(args) { + const target = (() => { + try { return targetFromArgs(args) } catch { return { workflowId: context.workflowId, nodeId: context.agentNodeId } } + })() + const graph = args.graph && typeof args.graph === 'object' + ? args.graph + : normalizeGraphResult((await handleGetSubworkflow(args)).graph, target.workflowId, target.nodeId) + const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [] + const edges = Array.isArray(graph?.edges) ? graph.edges : [] + const nodeIds = new Set() + const issues = [] + nodes.forEach((node, index) => { + const id = cleanString(node?.id) + if (!id) issues.push({ level: 'error', code: 'node_id_missing', index }) + if (id && nodeIds.has(id)) issues.push({ level: 'error', code: 'node_id_duplicate', nodeId: id }) + if (id) nodeIds.add(id) + const typeName = cleanString(node?.data?.n8n?.type) + if (!typeName) issues.push({ level: 'warn', code: 'node_type_missing', nodeId: id }) + }) + edges.forEach((edge, index) => { + const source = cleanString(edge?.source) + const target = cleanString(edge?.target) + if (!source || !target) issues.push({ level: 'error', code: 'edge_endpoint_missing', index }) + if (source && !nodeIds.has(source)) issues.push({ level: 'error', code: 'edge_source_missing', edgeId: edge?.id, source }) + if (target && !nodeIds.has(target)) issues.push({ level: 'error', code: 'edge_target_missing', edgeId: edge?.id, target }) + }) + return { + ok: !issues.some((issue) => issue.level === 'error'), + nodesCount: nodes.length, + edgesCount: edges.length, + issues, + } +} + +const tools = [ + { + name: 'ndc_get_context', + description: 'Return selected NDC Agent Core target context and safety contract.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'ndc_get_subworkflow', + description: 'Read the selected Engine second-level workflow graph from dc.subworkflow.json.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: 'ndc_apply_subworkflow_patch', + description: 'Apply safe graph patch operations to the selected Engine second-level workflow.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + intent: { type: 'string' }, + expectedUpdatedAt: { type: 'string' }, + operations: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + }, + }, + required: ['operations'], + additionalProperties: false, + }, + }, + { + name: 'ndc_search_nodes', + description: 'Search NDC node definitions from the pinned v2.3.2 schema catalog.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'number' }, + schemaVersion: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: 'ndc_get_node_definition', + description: 'Return a compact NDC node definition, including properties, credentials, inputs, and outputs.', + inputSchema: { + type: 'object', + properties: { + nodeType: { type: 'string' }, + schemaVersion: { type: 'string' }, + maxProperties: { type: 'number' }, + }, + required: ['nodeType'], + additionalProperties: false, + }, + }, + { + name: 'ndc_validate_subworkflow', + description: 'Validate a second-level workflow graph or the selected saved graph for basic structural issues.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + graph: { type: 'object', additionalProperties: true }, + }, + additionalProperties: false, + }, + }, +] + +async function callTool(name, args) { + if (name === 'ndc_get_context') return handleGetContext() + if (name === 'ndc_get_subworkflow') return handleGetSubworkflow(args) + if (name === 'ndc_apply_subworkflow_patch') return handleApplyPatch(args) + if (name === 'ndc_search_nodes') return handleSearchNodes(args) + if (name === 'ndc_get_node_definition') return handleGetNodeDefinition(args) + if (name === 'ndc_validate_subworkflow') return handleValidateSubworkflow(args) + throw new Error(`unknown_tool:${name}`) +} + +async function handleRequest(message) { + const id = message?.id + const method = String(message?.method || '') + if (!method) return + if (method.startsWith('notifications/')) return + + try { + if (method === 'initialize') { + return writeMessage(jsonRpcResult(id, { + protocolVersion: message?.params?.protocolVersion || '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'ndc-agent-mcp', version: '0.1.0' }, + })) + } + if (method === 'ping') return writeMessage(jsonRpcResult(id, {})) + if (method === 'tools/list') return writeMessage(jsonRpcResult(id, { tools })) + if (method === 'tools/call') { + const name = cleanString(message?.params?.name) + const args = message?.params?.arguments && typeof message.params.arguments === 'object' + ? message.params.arguments + : {} + const result = await callTool(name, args) + return writeMessage(jsonRpcResult(id, toolText(result))) + } + if (method === 'resources/list') return writeMessage(jsonRpcResult(id, { resources: [] })) + if (method === 'prompts/list') return writeMessage(jsonRpcResult(id, { prompts: [] })) + return writeMessage(jsonRpcError(id, -32601, `method_not_found:${method}`)) + } catch (error) { + return writeMessage(jsonRpcError(id, -32000, error?.message || error, error?.payload)) + } +} + +function parseJsonMessage(raw) { + try { + return JSON.parse(raw) + } catch { + writeMessage(jsonRpcError(null, -32700, 'parse_error')) + return null + } +} + +function consumeJsonLineMessages() { + for (;;) { + const newlineIndex = inputBuffer.indexOf(0x0a) + if (newlineIndex < 0) return + const line = inputBuffer.subarray(0, newlineIndex).toString('utf8') + inputBuffer = inputBuffer.subarray(newlineIndex + 1) + const trimmed = line.trim() + if (!trimmed) continue + const message = parseJsonMessage(trimmed) + if (message) void handleRequest(message) + } +} + +function consumeContentLengthMessages() { + for (;;) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n') + const altHeaderEnd = headerEnd < 0 ? inputBuffer.indexOf('\n\n') : -1 + const boundary = headerEnd >= 0 ? headerEnd : altHeaderEnd + if (boundary < 0) return + + const separatorLength = headerEnd >= 0 ? 4 : 2 + const header = inputBuffer.subarray(0, boundary).toString('utf8') + const match = /content-length:\s*(\d+)/i.exec(header) + if (!match) { + inputBuffer = inputBuffer.subarray(boundary + separatorLength) + writeMessage(jsonRpcError(null, -32600, 'content_length_missing')) + continue + } + + const contentLength = Number(match[1]) + const bodyStart = boundary + separatorLength + const bodyEnd = bodyStart + contentLength + if (inputBuffer.length < bodyEnd) return + + const body = inputBuffer.subarray(bodyStart, bodyEnd).toString('utf8') + inputBuffer = inputBuffer.subarray(bodyEnd) + const message = parseJsonMessage(body) + if (message) void handleRequest(message) + } +} + +function consumeInputBuffer() { + if (!inputBuffer.length) return + + if (!transportMode) { + const trimmedStart = inputBuffer.toString('utf8', 0, Math.min(inputBuffer.length, 32)).trimStart() + if (trimmedStart.startsWith('Content-Length:') || trimmedStart.toLowerCase().startsWith('content-length:')) { + transportMode = 'content-length' + } else if (trimmedStart.startsWith('{')) { + transportMode = 'json-lines' + } else if (inputBuffer.length < 32) { + return + } else { + transportMode = 'json-lines' + } + } + + if (transportMode === 'content-length') consumeContentLengthMessages() + else consumeJsonLineMessages() +} + +process.stdin.on('data', (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, Buffer.from(chunk)]) + consumeInputBuffer() +}) + +process.stdin.on('end', () => { + if (transportMode === 'json-lines') { + const line = inputBuffer.toString('utf8').trim() + inputBuffer = Buffer.alloc(0) + if (!line) return + const message = parseJsonMessage(line) + if (message) void handleRequest(message) + } +}) + +if (process.env.NDC_AGENT_MCP_DEBUG_JSONL === '1') { + process.stdin.setEncoding('utf8') +} + +process.on('uncaughtException', (error) => { + writeMessage(jsonRpcError(null, -32000, error?.message || error)) +}) + +process.on('unhandledRejection', (reason) => { + writeMessage(jsonRpcError(null, -32000, reason?.message || reason)) +}) + +if (process.stdin.isTTY) { + process.stderr.write('ndc-agent-mcp expects JSON-RPC messages on stdin.\n') +} +'@ + + Set-Content -Path $workerPath -Value $workerCode -Encoding UTF8 + Set-Content -Path $ndcAgentMcpServerPath -Value $ndcAgentMcpServerCode -Encoding UTF8 + + $startCode = @" +`$ErrorActionPreference = 'Stop' +`$env:AI_BRIDGE_HOST = '0.0.0.0' +`$env:AI_BRIDGE_PORT = '$Port' +`$env:AI_BRIDGE_CODEX_CWD = $(ConvertTo-PsSingleQuoted $WorkspacePath) +`$env:AI_BRIDGE_CODEX_BIN = 'codex' +`$env:AI_BRIDGE_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]' +`$env:AI_BRIDGE_ALLOW_ORIGIN = '*' +`$env:AI_BRIDGE_HUB_URL = $(ConvertTo-PsSingleQuoted $HubUrl) +`$env:AI_BRIDGE_HUB_URLS = $(ConvertTo-PsSingleQuoted $hubUrlsText) +`$env:AI_BRIDGE_PAIRING_CODE = $(ConvertTo-PsSingleQuoted $PairingCode) +`$env:AI_BRIDGE_MACHINE_NAME = $(ConvertTo-PsSingleQuoted $resolvedMachineName) +`$env:AI_BRIDGE_ACTIVE_MIRROR_DIR = $(ConvertTo-PsSingleQuoted $activeMirror) +`$env:AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE = '1' +`$env:Path = $(ConvertTo-PsSingleQuoted $nodeDir) + ';' + $(ConvertTo-PsSingleQuoted $npmPrefix) + ';' + `$env:Path +Set-Location $(ConvertTo-PsSingleQuoted $Root) +& $(ConvertTo-PsSingleQuoted $nodeSource) $(ConvertTo-PsSingleQuoted $workerPath) *>> $(ConvertTo-PsSingleQuoted $logPath) +"@ + Set-Content -Path $startScript -Value $startCode -Encoding UTF8 + + $watchdogCode = @" +`$ErrorActionPreference = 'SilentlyContinue' +`$startScript = $(ConvertTo-PsSingleQuoted $startScript) +`$logPath = $(ConvertTo-PsSingleQuoted $logPath) +`$healthUrl = $(ConvertTo-PsSingleQuoted "http://127.0.0.1:$Port$HealthPath") +`$healthy = `$false +try { + `$request = [System.Net.HttpWebRequest]::Create(`$healthUrl) + `$request.Method = 'GET' + `$request.Proxy = `$null + `$request.Timeout = 2000 + `$request.ReadWriteTimeout = 2000 + `$response = `$request.GetResponse() + try { + `$stream = `$response.GetResponseStream() + `$reader = New-Object System.IO.StreamReader(`$stream) + `$body = `$reader.ReadToEnd() + `$healthy = `$response.StatusCode -eq 200 -and `$body -match '"ok"\s*:\s*true' + } finally { + `$response.Close() + } +} catch {} +if (-not `$healthy) { + try { + Add-Content -Path `$logPath -Value ("[{0}] watchdog: health probe failed; starting bridge." -f (Get-Date).ToString('s')) -Encoding UTF8 + } catch {} + Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-WindowStyle', 'Hidden', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `$startScript) -WindowStyle Hidden | Out-Null +} +"@ + Set-Content -Path $watchdogScript -Value $watchdogCode -Encoding UTF8 + + $config = [ordered]@{ + service = 'NDC AI Workspace Bridge' + port = $Port + installRoot = $Root + workspace = $WorkspacePath + codexHome = $bridgeCodexHome + worker = $workerPath + startScript = $startScript + watchdogScript = $watchdogScript + watchdogEnabled = [bool]$EnableWatchdog + taskName = $TaskName + watchdogTaskName = "$TaskName Watchdog" + healthPath = $HealthPath + conversationPath = $ConversationPath + activeMirror = $activeMirror + } + $config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8 + Write-Ok "Installed to $Root" + return @{ + WorkerPath = $workerPath + StartScript = $startScript + WatchdogScript = $watchdogScript + ConfigPath = $configPath + Logs = $logs + } +} + +function Register-BridgeAutostart { + param( + [string]$StartScript, + [string]$WatchdogScript + ) + $watchdogTaskName = "$TaskName Watchdog" + if (-not $EnableWatchdog) { + try { + Stop-ScheduledTask -TaskName $watchdogTaskName -ErrorAction SilentlyContinue + } catch {} + try { + Unregister-ScheduledTask -TaskName $watchdogTaskName -Confirm:$false -ErrorAction SilentlyContinue + Write-Ok "Watchdog task disabled: $watchdogTaskName" + } catch {} + } + + if ($NoAutostart) { + Write-Warn 'Autostart skipped by flag.' + return + } + + Write-Step 'Registering Windows autostart' + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`"" + $triggers = @() + $triggers += New-ScheduledTaskTrigger -AtLogOn + $triggers += New-ScheduledTaskTrigger -AtStartup + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Days 3650) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Settings $settings -Description 'NDC AI Workspace Bridge for NODE.DC AI Workspace' -RunLevel Highest -Force | Out-Null + Write-Ok "Scheduled task registered: $TaskName" + + if ($EnableWatchdog -and $WatchdogScript) { + $watchdogAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$WatchdogScript`"" + $watchdogTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650) + $watchdogSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew + Register-ScheduledTask -TaskName $watchdogTaskName -Action $watchdogAction -Trigger $watchdogTrigger -Settings $watchdogSettings -Description 'NDC AI Workspace Bridge watchdog' -RunLevel Highest -Force | Out-Null + Write-Ok "Watchdog task registered: $watchdogTaskName" + } +} + +function Start-BridgeNow { + param([string]$StartScript) + + Write-Step 'Starting bridge now' + Stop-BridgeNow + + Start-Process -FilePath 'powershell.exe' -ArgumentList "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`"" -WindowStyle Hidden | Out-Null + Write-Ok 'Bridge process started.' +} + +function Stop-BridgeNow { + try { + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + } catch {} + try { + Stop-ScheduledTask -TaskName "$TaskName Watchdog" -ErrorAction SilentlyContinue + } catch {} + + try { + $escapedRoot = [Regex]::Escape($InstallRoot) + $processes = Get-CimInstance Win32_Process | + Where-Object { + $_.CommandLine -match 'worker\.mjs' -and + ($_.CommandLine -match $escapedRoot -or $_.CommandLine -match 'AIWorkspaceBridge') + } + foreach ($process in $processes) { + try { + Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop + Write-Ok "Stopped existing bridge process: $($process.ProcessId)" + } catch {} + } + } catch {} +} + +function Get-BridgeLogTail { + $logPath = Join-Path $InstallRoot 'logs\bridge.log' + if (-not (Test-Path $logPath)) { return '' } + try { + return ((Get-Content -Path $logPath -Tail 80 -ErrorAction Stop) -join [Environment]::NewLine) + } catch { + return '' + } +} + +function Get-PortOwnerReport { + param([int]$LocalPort = $Port) + + try { + $listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue | + Sort-Object OwningProcess, LocalAddress -Unique + if (-not $listeners) { return '' } + + $lines = @() + foreach ($listener in $listeners) { + $processId = [int]$listener.OwningProcess + $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue + if ($process) { + $commandLine = ([string]$process.CommandLine).Trim() + if ($commandLine.Length -gt 500) { $commandLine = $commandLine.Substring(0, 500) + '...' } + $lines += ("{0}:{1} PID {2} {3} {4}" -f $listener.LocalAddress, $listener.LocalPort, $processId, $process.Name, $commandLine) + } else { + $lines += ("{0}:{1} PID {2}" -f $listener.LocalAddress, $listener.LocalPort, $processId) + } + } + return ($lines -join [Environment]::NewLine) + } catch { + return '' + } +} + +function Test-BridgeProcess { + param([object]$Process) + if (-not $Process) { return $false } + $commandLine = [string]$Process.CommandLine + if (-not $commandLine) { return $false } + try { + $escapedRoot = [Regex]::Escape($InstallRoot) + return ( + $commandLine -match 'worker\.mjs' -and + ($commandLine -match $escapedRoot -or $commandLine -match 'AIWorkspaceBridge') + ) + } catch { + return ($commandLine -match 'worker\.mjs' -and $commandLine -match 'AIWorkspaceBridge') + } +} + +function Get-BlockingPortOwners { + param([int]$LocalPort) + + try { + $listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue | + Sort-Object OwningProcess, LocalAddress -Unique + if (-not $listeners) { return @() } + + $blocking = @() + foreach ($listener in $listeners) { + $processId = [int]$listener.OwningProcess + $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue + if (Test-BridgeProcess $process) { continue } + $blocking += [pscustomobject]@{ + LocalAddress = $listener.LocalAddress + LocalPort = $listener.LocalPort + ProcessId = $processId + Name = if ($process) { $process.Name } else { '' } + CommandLine = if ($process) { [string]$process.CommandLine } else { '' } + } + } + return @($blocking) + } catch { + return @() + } +} + +function Resolve-BridgePort { + $requested = [int]$Port + for ($candidate = $requested; $candidate -le ($requested + 20); $candidate++) { + $blocking = @(Get-BlockingPortOwners -LocalPort $candidate) + if ($blocking.Count -eq 0) { + if ($candidate -ne $requested) { + Write-Warn "TCP $requested is occupied by another service; using TCP $candidate for local bridge health." + } + return $candidate + } + } + + $report = Get-PortOwnerReport -LocalPort $requested + if ($report) { + throw "No free bridge port near $requested. Current listener on TCP ${requested}:`n$report" + } + throw "No free bridge port near $requested." +} + +function Try-StartBridgeViaTask { + if ($NoAutostart) { return } + try { + Start-ScheduledTask -TaskName $TaskName + Write-Ok 'Autostart task started for validation.' + } catch { + Write-Warn "Could not start scheduled task: $($_.Exception.Message)" + } +} + +function Ensure-Firewall { + if ($NoFirewall) { + Write-Warn 'Firewall rule skipped by flag.' + return + } + + Write-Step 'Configuring Windows Firewall' + $displayName = "NDC AI Workspace Bridge $Port" + $existing = Get-NetFirewallRule -DisplayName $displayName -ErrorAction SilentlyContinue + if ($existing) { + $existing | Set-NetFirewallRule -Enabled True -Profile Domain,Private,Public -Direction Inbound -Action Allow + Write-Ok "Firewall rule updated for TCP ${Port}: $displayName" + return + } + + New-NetFirewallRule -DisplayName $displayName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port -Profile Domain,Private,Public | Out-Null + Write-Ok "Firewall rule added for TCP $Port" +} + +function Normalize-PairingCode { + param([string]$Value) + if (-not $Value) { return '' } + return (($Value -replace '[^A-Za-z0-9]', '').ToUpperInvariant()) +} + +function Get-LocalBridgeHealth { + $healthUrl = "http://127.0.0.1:$Port$HealthPath" + try { + $request = [System.Net.HttpWebRequest]::Create($healthUrl) + $request.Method = 'GET' + $request.Proxy = $null + $request.Timeout = 2000 + $request.ReadWriteTimeout = 2000 + $response = $request.GetResponse() + try { + $stream = $response.GetResponseStream() + $reader = New-Object System.IO.StreamReader($stream) + $body = $reader.ReadToEnd() + $data = $null + try { $data = $body | ConvertFrom-Json } catch {} + return [pscustomobject]@{ + Ok = ($response.StatusCode -eq 200 -and $null -ne $data -and $data.ok -eq $true) + Url = $healthUrl + StatusCode = [int]$response.StatusCode + Body = $body + Data = $data + Error = '' + } + } finally { + $response.Close() + } + } catch { + $statusCode = 0 + $body = '' + $response = $_.Exception.Response + if ($response) { + try { + $statusCode = [int]$response.StatusCode + $stream = $response.GetResponseStream() + if ($stream) { + $reader = New-Object System.IO.StreamReader($stream) + $body = $reader.ReadToEnd() + } + } catch {} + try { $response.Close() } catch {} + } + return [pscustomobject]@{ + Ok = $false + Url = $healthUrl + StatusCode = $statusCode + Body = $body + Data = $null + Error = $_.Exception.Message + } + } +} + +function Wait-Health { + Write-Step 'Checking bridge health' + $lastError = '' + $lastStatusCode = 0 + $lastBody = '' + for ($i = 0; $i -lt 30; $i++) { + $health = Get-LocalBridgeHealth + if ($health.Ok) { + Write-Ok "Health OK: $($health.Url)" + return $health.Data + } + $lastError = $health.Error + $lastStatusCode = [int]$health.StatusCode + $lastBody = [string]$health.Body + Start-Sleep -Seconds 1 + } + Write-Warn "Bridge did not answer with JSON health yet: http://127.0.0.1:$Port$HealthPath" + if ($lastStatusCode -gt 0) { Write-Warn "Last health HTTP status: $lastStatusCode" } + if ($lastError) { Write-Warn "Last health error: $lastError" } + if ($lastBody) { + $bodyPreview = ($lastBody -replace '\s+', ' ').Trim() + if ($bodyPreview.Length -gt 500) { $bodyPreview = $bodyPreview.Substring(0, 500) + '...' } + Write-Warn "Last health body: $bodyPreview" + } + $portOwner = Get-PortOwnerReport + if ($portOwner) { + Write-Host "Current listener on TCP ${Port}:" -ForegroundColor Yellow + Write-Host $portOwner -ForegroundColor Yellow + } else { + Write-Warn "No listener detected on TCP $Port." + } + return $null +} + +function Wait-HubConnection { + param([object]$InitialHealth) + + if (-not $HubUrl -or -not $PairingCode) { + return $true + } + + Write-Step 'Checking Cloud Hub connection' + $expectedPairing = Normalize-PairingCode $PairingCode + $lastHubError = '' + $lastHubUrl = '' + for ($i = 0; $i -lt 60; $i++) { + $health = if ($i -eq 0 -and $InitialHealth) { $InitialHealth } else { (Get-LocalBridgeHealth).Data } + if ($health) { + $hub = $health.hub + $hubConnected = ($health.hubConnected -eq $true) + if ($hub) { + $hubConnected = $hubConnected -or ($hub.connected -eq $true) + $lastHubError = [string]$hub.lastError + $lastHubUrl = [string]$hub.url + $actualPairing = Normalize-PairingCode ([string]$hub.pairingCode) + if ($hubConnected -and $actualPairing -eq $expectedPairing) { + if ($lastHubUrl) { + Write-Ok "Cloud Hub connected: $lastHubUrl" + } else { + Write-Ok 'Cloud Hub connected.' + } + return $true + } + } + } + Start-Sleep -Seconds 1 + } + + Write-Warn 'Cloud Hub connection was not confirmed.' + if ($lastHubError) { Write-Warn "Last Hub error: $lastHubError" } + $hubUrlCandidates = @(Get-HubUrlCandidates) + if ($hubUrlCandidates.Count -gt 0) { + Write-Host "Hub candidates:" -ForegroundColor Yellow + foreach ($candidate in $hubUrlCandidates) { + Write-Host " $candidate" -ForegroundColor Yellow + } + } + return $false +} + +function Get-BridgeIps { + $ips = @() + try { + $ips = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | + Sort-Object InterfaceMetric | + Select-Object -ExpandProperty IPAddress -Unique + } catch {} + + if (-not $ips -or $ips.Count -eq 0) { + $ips = [System.Net.Dns]::GetHostAddresses($env:COMPUTERNAME) | + Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.IPAddressToString -notlike '127.*' } | + ForEach-Object { $_.IPAddressToString } + } + return @($ips) +} + +function Print-Result { + param( + [string]$Root, + [string]$WorkspacePath, + [bool]$Healthy, + [bool]$HubHealthy + ) + + $ips = Get-BridgeIps + $endpoint = if ($ips.Count -gt 0) { "http://$($ips[0]):$Port" } else { "http://:$Port" } + $hubUrlCandidates = @(Get-HubUrlCandidates) + + Write-Step 'Done' + Write-Host "AI Workspace settings:" + Write-Host " Type: Codex worker" + Write-Host " Model: codex" + if ($HubUrl -and $PairingCode) { + Write-Host " Connection: Cloud Hub" -ForegroundColor Green + if ($HubHealthy) { + Write-Host " Hub connection: confirmed" -ForegroundColor Green + } else { + Write-Host " Hub connection: not confirmed" -ForegroundColor Yellow + } + } else { + Write-Host " Bridge endpoint: $endpoint" -ForegroundColor Green + } + Write-Host "" + Write-Host "Local health:" + Write-Host " http://127.0.0.1:$Port$HealthPath" + Write-Host "" + Write-Host "Install root:" + Write-Host " $Root" + Write-Host "Workspace:" + Write-Host " $WorkspacePath" + Write-Host "Autostart task:" + Write-Host " $TaskName" + if ($hubUrlCandidates.Count -gt 0 -and $PairingCode) { + Write-Host "Hub candidates:" + foreach ($candidate in $hubUrlCandidates) { + Write-Host " $candidate" + } + Write-Host "Pairing code:" + Write-Host " $PairingCode" -ForegroundColor Green + } + + if ($ips.Count -gt 1) { + Write-Host "" + Write-Host "Other possible endpoints:" + foreach ($ip in $ips | Select-Object -Skip 1) { + Write-Host " http://${ip}:$Port" + } + } + + $copyText = @" +Type: Codex worker +Model: codex +Connection: $(if ($HubUrl -and $PairingCode) { 'Cloud Hub' } else { $endpoint }) +Hub connection: $(if ($HubUrl -and $PairingCode) { if ($HubHealthy) { 'confirmed' } else { 'not confirmed' } } else { 'not used' }) +Health: http://127.0.0.1:$Port$HealthPath +Workspace: $WorkspacePath +Hub candidates: $($hubUrlCandidates -join ', ') +Pairing code: $PairingCode +"@ + Write-InstallResult $copyText + try { + Set-Clipboard -Value $copyText + Write-Ok 'Connection details copied to clipboard.' + } catch {} + + if (-not $Healthy) { + Write-Warn 'If Engine cannot connect, check Windows Firewall profile and bridge.log in the install root.' + $logTail = Get-BridgeLogTail + if ($logTail) { + Write-Host "" + Write-Host "bridge.log tail:" -ForegroundColor Yellow + Write-Host $logTail + try { + Add-Content -Path (Get-ResultPath) -Value "`nbridge.log tail:`n$logTail" -Encoding UTF8 + } catch {} + } + } +} + +function Main { + if (-not $Confirmed) { + Write-Host "" + Write-Host "NDC AI Workspace Bridge will install or update Node.js, Codex CLI, a local bridge service, autostart, and firewall access." + Write-Host "Target port: $Port" + } + + Request-Elevation + $workspacePath = Get-WorkspacePath + Ensure-Node + Ensure-Codex + Ensure-CodexLogin + Repair-CodexMcpConfig + Stop-BridgeNow + $script:Port = Resolve-BridgePort + $files = Write-WorkerFiles -Root $InstallRoot -WorkspacePath $workspacePath + Ensure-Firewall + Register-BridgeAutostart -StartScript $files.StartScript -WatchdogScript $files.WatchdogScript + Start-BridgeNow -StartScript $files.StartScript + $health = Wait-Health + if (-not $health) { + Try-StartBridgeViaTask + $health = Wait-Health + } + $hubHealthy = $true + if ($health -and $HubUrl -and $PairingCode) { + $hubHealthy = Wait-HubConnection -InitialHealth $health + } elseif ($HubUrl -and $PairingCode) { + $hubHealthy = $false + } + Print-Result -Root $InstallRoot -WorkspacePath $workspacePath -Healthy ([bool]$health -and [bool]$hubHealthy) -HubHealthy ([bool]$hubHealthy) +} + +try { + Main +} catch { + $errorText = @" +NDC AI Workspace Bridge install failed. + +Error: +$($_.Exception.Message) + +Script: +$PSCommandPath + +Install root: +$InstallRoot +"@ + Write-Host "" + Write-Host $errorText -ForegroundColor Red + Write-InstallResult $errorText +}