Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2530cd4fc8 | ||
|
|
b25fb0073e | ||
|
|
c01846f7fd | ||
|
|
dfd397803f | ||
|
|
267f1592c4 | ||
|
|
668ac7fa88 | ||
|
|
43a476e967 | ||
|
|
adbfab5c25 | ||
|
|
02f1284f7f | ||
|
|
a014ce555a | ||
|
|
db3c13c463 | ||
|
|
7c0bf382ba | ||
|
|
6e9c5a88b4 | ||
|
|
0bf22a323f | ||
|
|
cc997576cf | ||
|
|
05f193df7b | ||
|
|
c9b5bb1b7a | ||
|
|
ba1013cd35 | ||
|
|
ec6428702b | ||
|
|
e08ebb2057 | ||
|
|
9683f90f7e | ||
|
|
06cb986aa6 | ||
|
|
a85c2f1700 | ||
|
|
bd2a0d1bec | ||
|
|
df9722cd16 | ||
|
|
dffa4907ec | ||
|
|
e567d35438 | ||
|
|
62f52fc534 | ||
|
|
69f221942c | ||
|
|
7749225f71 | ||
|
|
ae322c53cc | ||
|
|
37da426ab4 | ||
|
|
591f55bef9 | ||
|
|
aabaadbe1d | ||
|
|
3ab14e8de6 | ||
|
|
40634138bc | ||
|
|
b17087b610 | ||
|
|
71f58e7c5f | ||
|
|
927e4e1281 | ||
|
|
2e56a5e9f4 | ||
|
|
296d07a0d6 | ||
|
|
0d8a844af8 | ||
|
|
bf9cef4cd5 | ||
|
|
9dde33aba7 | ||
|
|
0fefff3b0a | ||
|
|
1122c19648 | ||
|
|
f06359a1fc | ||
|
|
72f420b6f6 |
47
.github/workflows/release-ui.yml
vendored
Normal file
47
.github/workflows/release-ui.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Release UI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call: {}
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 20
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-ui:
|
||||||
|
# Automated via reusable call (main releases); manual runs allowed on dev.
|
||||||
|
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
|
- name: Install Cloudflare worker deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: packages/cloudflare
|
||||||
|
|
||||||
|
- name: Build UI
|
||||||
|
run: npm run build --workspace @codenomad/ui
|
||||||
|
|
||||||
|
- name: Publish UI zip + update manifest
|
||||||
|
working-directory: packages/cloudflare
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
CODENOMAD_R2_BUCKET: ${{ vars.CODENOMAD_R2_BUCKET }}
|
||||||
|
run: npm run release:ui
|
||||||
7
.github/workflows/reusable-release.yml
vendored
7
.github/workflows/reusable-release.yml
vendored
@@ -69,6 +69,13 @@ jobs:
|
|||||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
release-ui:
|
||||||
|
needs: prepare-release
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
uses: ./.github/workflows/release-ui.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
publish-server:
|
publish-server:
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,4 +7,9 @@ release/
|
|||||||
.electron-vite/
|
.electron-vite/
|
||||||
out/
|
out/
|
||||||
.dir-locals.el
|
.dir-locals.el
|
||||||
.opencode/bashOutputs/
|
.opencode/bashOutputs/
|
||||||
|
|
||||||
|
# Local runtime artifacts
|
||||||
|
.codenomad/
|
||||||
|
.tmp/
|
||||||
|
packages/cloudflare/.wrangler/
|
||||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -1096,9 +1096,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
||||||
"integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==",
|
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
@@ -1632,7 +1632,6 @@
|
|||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -2271,7 +2270,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/buffer-crc32": {
|
"node_modules/buffer-crc32": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "*"
|
||||||
@@ -3674,7 +3672,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/fd-slicer": {
|
"node_modules/fd-slicer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pend": "~1.2.0"
|
"pend": "~1.2.0"
|
||||||
@@ -5352,7 +5349,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
@@ -7324,7 +7320,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/yauzl": {
|
"node_modules/yauzl": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-crc32": "~0.2.3",
|
"buffer-crc32": "~0.2.3",
|
||||||
@@ -7389,7 +7384,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -7423,7 +7418,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -7433,12 +7428,14 @@
|
|||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"codenomad": "dist/bin.js"
|
"codenomad": "dist/bin.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/yauzl": "^2.10.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
@@ -7458,18 +7455,18 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.1",
|
"@opencode-ai/sdk": "1.1.11",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
|||||||
1
packages/cloudflare/.gitignore
vendored
Normal file
1
packages/cloudflare/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
1515
packages/cloudflare/package-lock.json
generated
Normal file
1515
packages/cloudflare/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/cloudflare/package.json
Normal file
14
packages/cloudflare/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@codenomad/ui-host-worker",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||||
|
"release:ui": "node ./scripts/release-ui.mjs",
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/cloudflare/release-config.json
Normal file
4
packages/cloudflare/release-config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"minServerVersion": "0.7.5",
|
||||||
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
|
}
|
||||||
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { createHash } from "crypto"
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const repoRoot = path.resolve(root, "..", "..")
|
||||||
|
|
||||||
|
const releaseConfigPath = path.join(root, "release-config.json")
|
||||||
|
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||||
|
const serverPackageJsonPath = path.join(repoRoot, "packages/server/package.json")
|
||||||
|
|
||||||
|
const distDir = path.join(root, "dist")
|
||||||
|
const manifestPath = path.join(distDir, "version.json")
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2))
|
||||||
|
|
||||||
|
function getArgValue(flag) {
|
||||||
|
const idx = process.argv.indexOf(flag)
|
||||||
|
if (idx === -1) return null
|
||||||
|
return process.argv[idx + 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipPath = getArgValue("--zip")
|
||||||
|
|
||||||
|
if (!zipPath) {
|
||||||
|
console.error("Usage: node scripts/build-manifest.mjs --zip <path-to-ui-zip>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedZipPath = path.resolve(process.cwd(), zipPath)
|
||||||
|
if (!fs.existsSync(resolvedZipPath)) {
|
||||||
|
console.error(`Zip not found: ${resolvedZipPath}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseConfig = JSON.parse(fs.readFileSync(releaseConfigPath, "utf-8"))
|
||||||
|
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||||
|
const serverPackageJson = JSON.parse(fs.readFileSync(serverPackageJsonPath, "utf-8"))
|
||||||
|
|
||||||
|
const bucket = process.env.CODENOMAD_R2_BUCKET
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiVersion = uiPackageJson.version
|
||||||
|
const serverVersion = serverPackageJson.version
|
||||||
|
|
||||||
|
if (!uiVersion || !serverVersion) {
|
||||||
|
console.error("Missing version fields in package.json")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256 = createHash("sha256").update(fs.readFileSync(resolvedZipPath)).digest("hex")
|
||||||
|
|
||||||
|
const uiPackageURL = `https://download.codenomad.neuralnomads.ai/ui/ui-${uiVersion}.zip`
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
minServerVersion: releaseConfig.minServerVersion,
|
||||||
|
latestUIVersion: uiVersion,
|
||||||
|
uiPackageURL,
|
||||||
|
sha256,
|
||||||
|
latestServerVersion: serverVersion,
|
||||||
|
latestServerUrl: releaseConfig.latestServerUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(distDir, { recursive: true })
|
||||||
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
|
||||||
|
|
||||||
|
const headersPath = path.join(distDir, "_headers")
|
||||||
|
fs.writeFileSync(
|
||||||
|
headersPath,
|
||||||
|
"/version.json\n Cache-Control: no-cache\n Content-Type: application/json; charset=utf-8\n",
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Wrote ${manifestPath}`)
|
||||||
|
console.log(`Wrote ${headersPath}`)
|
||||||
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { execFileSync } from "child_process"
|
||||||
|
import fs from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const repoRoot = path.resolve(root, "..", "..")
|
||||||
|
|
||||||
|
const r2Bucket = process.env.CODENOMAD_R2_BUCKET
|
||||||
|
|
||||||
|
if (!r2Bucket) {
|
||||||
|
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||||
|
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||||
|
const uiVersion = uiPackageJson.version
|
||||||
|
|
||||||
|
if (!uiVersion) {
|
||||||
|
console.error("Missing packages/ui/package.json version")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist")
|
||||||
|
if (!fs.existsSync(uiBuildDir)) {
|
||||||
|
console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-release-"))
|
||||||
|
const zipPath = path.join(tmpDir, `ui-${uiVersion}.zip`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Zip the CONTENTS of the dist dir (so index.html is at zip root).
|
||||||
|
execFileSync("/usr/bin/zip", ["-q", "-r", zipPath, "."], { cwd: uiBuildDir, stdio: "inherit" })
|
||||||
|
|
||||||
|
// Upload to R2.
|
||||||
|
const objectKey = `ui/ui-${uiVersion}.zip`
|
||||||
|
console.log(`[release-ui] Uploading ${zipPath} -> r2://${r2Bucket}/${objectKey}`)
|
||||||
|
|
||||||
|
execFileSync(
|
||||||
|
"npx",
|
||||||
|
["wrangler", "r2", "object", "put", "--remote", `${r2Bucket}/${objectKey}`, "--file", zipPath],
|
||||||
|
{ cwd: root, stdio: "inherit" },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate version.json into packages/cloudflare/dist
|
||||||
|
console.log("[release-ui] Generating version.json")
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(root, "scripts/build-manifest.mjs"), "--zip", zipPath],
|
||||||
|
{
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CODENOMAD_R2_BUCKET: r2Bucket,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log("[release-ui] Deploying worker")
|
||||||
|
execFileSync("npx", ["wrangler", "deploy"], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("[release-ui] Done")
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
9
packages/cloudflare/src/index.ts
Normal file
9
packages/cloudflare/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Env {
|
||||||
|
ASSETS: { fetch: (request: Request) => Promise<Response> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
return env.ASSETS.fetch(request)
|
||||||
|
},
|
||||||
|
}
|
||||||
14
packages/cloudflare/wrangler.toml
Normal file
14
packages/cloudflare/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name = "codenomad-ui-host"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2026-01-22"
|
||||||
|
|
||||||
|
# Custom domain for the manifest host.
|
||||||
|
# Note: Custom domains apply to all paths on the hostname.
|
||||||
|
[[routes]]
|
||||||
|
pattern = "ui.codenomad.neuralnomads.ai"
|
||||||
|
custom_domain = true
|
||||||
|
|
||||||
|
[assets]
|
||||||
|
directory = "./dist"
|
||||||
|
binding = "ASSETS"
|
||||||
|
not_found_handling = "404-page"
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||||
|
import http from "node:http"
|
||||||
|
import https from "node:https"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
|
|||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
let pendingCliUrl: string | null = null
|
let pendingCliUrl: string | null = null
|
||||||
|
let pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
|
||||||
@@ -251,6 +254,15 @@ function showLoadingScreen(force = false) {
|
|||||||
loadLoadingScreen(mainWindow)
|
loadLoadingScreen(mainWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBootstrapTokenUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startCliPreload(url: string) {
|
function startCliPreload(url: string) {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
pendingCliUrl = url
|
pendingCliUrl = url
|
||||||
@@ -268,6 +280,13 @@ function startCliPreload(url: string) {
|
|||||||
showLoadingScreen(true)
|
showLoadingScreen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
|
||||||
|
// consuming the token in the hidden view and then failing in the main window.
|
||||||
|
if (isBootstrapTokenUrl(url)) {
|
||||||
|
finalizeCliSwap(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const view = new BrowserView({
|
const view = new BrowserView({
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -308,6 +327,75 @@ function finalizeCliSwap(url: string) {
|
|||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||||
|
let bootstrapExchangeInFlight = false
|
||||||
|
|
||||||
|
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||||
|
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const first = raw.split(";")[0] ?? ""
|
||||||
|
const index = first.indexOf("=")
|
||||||
|
if (index < 0) return null
|
||||||
|
|
||||||
|
const key = first.slice(0, index).trim()
|
||||||
|
const value = first.slice(index + 1).trim()
|
||||||
|
if (key !== name || !value) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||||
|
const target = new URL("/api/auth/token", baseUrl)
|
||||||
|
const body = JSON.stringify({ token })
|
||||||
|
|
||||||
|
const transport = target.protocol === "https:" ? https : http
|
||||||
|
|
||||||
|
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
|
||||||
|
const req = transport.request(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(body),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume()
|
||||||
|
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
req.on("error", reject)
|
||||||
|
req.write(body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.statusCode !== 200) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||||
|
if (!sessionId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.defaultSession.cookies.set({
|
||||||
|
url: baseUrl,
|
||||||
|
name: SESSION_COOKIE_NAME,
|
||||||
|
value: sessionId,
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function startCli() {
|
async function startCli() {
|
||||||
try {
|
try {
|
||||||
@@ -323,11 +411,53 @@ async function startCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function maybeExchangeAndNavigate(baseUrl: string) {
|
||||||
|
if (bootstrapExchangeInFlight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = pendingBootstrapToken
|
||||||
|
if (!token) {
|
||||||
|
startCliPreload(baseUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapExchangeInFlight = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await exchangeBootstrapToken(baseUrl, token)
|
||||||
|
pendingBootstrapToken = null
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
startCliPreload(`${baseUrl}/login`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startCliPreload(baseUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[cli] bootstrap token exchange failed:", error)
|
||||||
|
pendingBootstrapToken = null
|
||||||
|
startCliPreload(`${baseUrl}/login`)
|
||||||
|
} finally {
|
||||||
|
bootstrapExchangeInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cliManager.on("bootstrapToken", (token) => {
|
||||||
|
pendingBootstrapToken = token
|
||||||
|
|
||||||
|
const status = cliManager.getStatus()
|
||||||
|
if (status.url) {
|
||||||
|
void maybeExchangeAndNavigate(status.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
cliManager.on("ready", (status) => {
|
cliManager.on("ready", (status) => {
|
||||||
if (!status.url) {
|
if (!status.url) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startCliPreload(status.url)
|
|
||||||
|
void maybeExchangeAndNavigate(status.url)
|
||||||
})
|
})
|
||||||
|
|
||||||
cliManager.on("status", (status) => {
|
cliManager.on("status", (status) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
|
|||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
@@ -69,6 +70,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
|||||||
export declare interface CliProcessManager {
|
export declare interface CliProcessManager {
|
||||||
on(event: "status", listener: (status: CliStatus) => void): this
|
on(event: "status", listener: (status: CliStatus) => void): this
|
||||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||||
|
on(event: "bootstrapToken", listener: (token: string) => void): this
|
||||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||||
on(event: "error", listener: (error: Error) => void): this
|
on(event: "error", listener: (error: Error) => void): this
|
||||||
@@ -79,6 +81,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
|
private bootstrapToken: string | null = null
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
@@ -87,6 +90,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
|
this.bootstrapToken = null
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
@@ -227,11 +231,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue
|
const trimmed = line.trim()
|
||||||
console.info(`[cli][${stream}] ${line}`)
|
if (!trimmed) continue
|
||||||
this.emit("log", { stream, message: line })
|
|
||||||
|
|
||||||
const port = this.extractPort(line)
|
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
||||||
|
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
||||||
|
if (token && !this.bootstrapToken) {
|
||||||
|
this.bootstrapToken = token
|
||||||
|
this.emit("bootstrapToken", token)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[cli][${stream}] ${trimmed}`)
|
||||||
|
this.emit("log", { stream, message: trimmed })
|
||||||
|
|
||||||
|
const port = this.extractPort(trimmed)
|
||||||
if (port && this.status.state === "starting") {
|
if (port && this.status.state === "starting") {
|
||||||
const url = `http://127.0.0.1:${port}`
|
const url = `http://127.0.0.1:${port}`
|
||||||
console.info(`[cli] ready on ${url}`)
|
console.info(`[cli] ready on ${url}`)
|
||||||
@@ -271,7 +286,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--port", "0"]
|
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.8"
|
"@opencode-ai/plugin": "1.1.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { tool } from "@opencode-ai/plugin/tool"
|
import { tool } from "@opencode-ai/plugin/tool"
|
||||||
|
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
|
||||||
|
|
||||||
type BackgroundProcess = {
|
type BackgroundProcess = {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,11 +13,6 @@ type BackgroundProcess = {
|
|||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type CodeNomadConfig = {
|
|
||||||
instanceId: string
|
|
||||||
baseUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackgroundProcessOptions = {
|
type BackgroundProcessOptions = {
|
||||||
baseDir: string
|
baseDir: string
|
||||||
}
|
}
|
||||||
@@ -27,30 +23,10 @@ type ParsedCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
||||||
|
const requester = createCodeNomadRequester(config)
|
||||||
|
|
||||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
|
return requester.requestJson<T>(`/background-processes${path}`, init)
|
||||||
const base = config.baseUrl.replace(/\/+$/, "")
|
|
||||||
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
|
|
||||||
const headers = normalizeHeaders(init?.headers)
|
|
||||||
if (init?.body !== undefined) {
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = await response.text()
|
|
||||||
throw new Error(message || `Request failed with ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 204) {
|
|
||||||
return undefined as T
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await response.json()) as T
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
|
|||||||
|
|
||||||
if (char === "|" || char === "&" || char === ";") {
|
if (char === "|" || char === "&" || char === ";") {
|
||||||
flush()
|
flush()
|
||||||
const next = input[index + 1]
|
tokens.push(char)
|
||||||
if ((char === "|" || char === "&") && next === char) {
|
|
||||||
tokens.push(char + next)
|
|
||||||
index += 1
|
|
||||||
} else {
|
|
||||||
tokens.push(char)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
|
|||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSeparator(token: string) {
|
function isSeparator(token: string): boolean {
|
||||||
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
|
return token === "|" || token === "&" || token === ";"
|
||||||
}
|
}
|
||||||
|
|
||||||
function unquote(value: string) {
|
function unquote(token: string): string {
|
||||||
if (value.length >= 2) {
|
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||||||
const first = value[0]
|
return token.slice(1, -1)
|
||||||
const last = value[value.length - 1]
|
|
||||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
|
||||||
return value.slice(1, -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return value
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWithinBase(baseDir: string, target: string) {
|
function isWithinBase(base: string, candidate: string): boolean {
|
||||||
const relative = path.relative(baseDir, target)
|
const relative = path.relative(base, candidate)
|
||||||
if (!relative) return true
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||||
return !relative.startsWith("..") && !path.isAbsolute(relative)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
||||||
const output: Record<string, string> = {}
|
|
||||||
if (!headers) return output
|
|
||||||
|
|
||||||
if (headers instanceof Headers) {
|
|
||||||
headers.forEach((value, key) => {
|
|
||||||
output[key] = value
|
|
||||||
})
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(headers)) {
|
|
||||||
for (const [key, value] of headers) {
|
|
||||||
output[key] = value
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...headers }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,41 @@
|
|||||||
export type PluginEvent = {
|
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||||
type: string
|
|
||||||
properties?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CodeNomadConfig = {
|
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||||
instanceId: string
|
|
||||||
baseUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
|
||||||
return {
|
|
||||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
|
||||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCodeNomadClient(config: CodeNomadConfig) {
|
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||||
return {
|
const requester = createCodeNomadRequester(config)
|
||||||
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
|
|
||||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireEnv(key: string): string {
|
return {
|
||||||
const value = process.env[key]
|
postEvent: (event: PluginEvent) =>
|
||||||
if (!value || !value.trim()) {
|
requester.requestVoid("/event", {
|
||||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
method: "POST",
|
||||||
|
body: JSON.stringify(event),
|
||||||
|
}),
|
||||||
|
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
|
||||||
}
|
}
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
|
async function startPluginEvents(
|
||||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
|
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||||
const response = await fetch(url, {
|
onEvent: (event: PluginEvent) => void,
|
||||||
method: "POST",
|
) {
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(event),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
|
|
||||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
|
|
||||||
|
|
||||||
// Fail plugin startup if we cannot establish the initial connection.
|
// Fail plugin startup if we cannot establish the initial connection.
|
||||||
const initialBody = await connectWithRetries(url, 3)
|
const initialBody = await connectWithRetries(requester, 3)
|
||||||
|
|
||||||
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||||
void consumeWithReconnect(url, onEvent, initialBody)
|
void consumeWithReconnect(requester, onEvent, initialBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectWithRetries(url: string, maxAttempts: number) {
|
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
|
||||||
let lastError: unknown
|
let lastError: unknown
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
|
return await requester.requestSseBody("/events")
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
|
|
||||||
}
|
|
||||||
return response.body
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error
|
lastError = error
|
||||||
await delay(500 * attempt)
|
await delay(500 * attempt)
|
||||||
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
|
const url = requester.buildUrl("/events")
|
||||||
|
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function consumeWithReconnect(
|
async function consumeWithReconnect(
|
||||||
url: string,
|
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||||
onEvent: (event: PluginEvent) => void,
|
onEvent: (event: PluginEvent) => void,
|
||||||
initialBody: ReadableStream<Uint8Array>,
|
initialBody: ReadableStream<Uint8Array>,
|
||||||
) {
|
) {
|
||||||
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
|
|||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
if (!body) {
|
if (!body) {
|
||||||
body = await connectWithRetries(url, 3)
|
body = await connectWithRetries(requester, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
await consumeSseBody(body, onEvent)
|
await consumeSseBody(body, onEvent)
|
||||||
|
|||||||
124
packages/opencode-config/plugin/lib/request.ts
Normal file
124
packages/opencode-config/plugin/lib/request.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
export type PluginEvent = {
|
||||||
|
type: string
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodeNomadConfig = {
|
||||||
|
instanceId: string
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||||
|
return {
|
||||||
|
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||||
|
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||||
|
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
||||||
|
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||||
|
const authorization = buildInstanceAuthorizationHeader()
|
||||||
|
|
||||||
|
const buildUrl = (path: string) => {
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||||
|
return `${pluginBase}${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
|
||||||
|
const output: Record<string, string> = normalizeHeaders(headers)
|
||||||
|
output.Authorization = authorization
|
||||||
|
if (hasBody) {
|
||||||
|
output["Content-Type"] = output["Content-Type"] ?? "application/json"
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = buildUrl(path)
|
||||||
|
const hasBody = init?.body !== undefined
|
||||||
|
const headers = buildHeaders(init?.headers, hasBody)
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
|
const response = await fetchWithAuth(path, init)
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text().catch(() => "")
|
||||||
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
|
||||||
|
const response = await fetchWithAuth(path, init)
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text().catch(() => "")
|
||||||
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
|
||||||
|
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`SSE unavailable (${response.status})`)
|
||||||
|
}
|
||||||
|
return response.body as ReadableStream<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildUrl,
|
||||||
|
fetch: fetchWithAuth,
|
||||||
|
requestJson,
|
||||||
|
requestVoid,
|
||||||
|
requestSseBody,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(key: string): string {
|
||||||
|
const value = process.env[key]
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstanceAuthorizationHeader(): string {
|
||||||
|
const username = requireEnv("OPENCODE_SERVER_USERNAME")
|
||||||
|
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
|
||||||
|
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||||
|
return `Basic ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||||
|
const output: Record<string, string> = {}
|
||||||
|
if (!headers) return output
|
||||||
|
|
||||||
|
if (headers instanceof Headers) {
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
output[key] = value
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(headers)) {
|
||||||
|
for (const [key, value] of headers) {
|
||||||
|
output[key] = value
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...headers }
|
||||||
|
}
|
||||||
774
packages/server/package-lock.json
generated
774
packages/server/package-lock.json
generated
@@ -1,20 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
|
"undici": "^6.19.8",
|
||||||
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"codenomad": "dist/bin.js"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/yauzl": "^2.10.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
@@ -475,6 +485,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/accept-negotiator": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/ajv-compiler": {
|
"node_modules/@fastify/ajv-compiler": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
||||||
@@ -486,6 +505,15 @@
|
|||||||
"fast-uri": "^2.0.0"
|
"fast-uri": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/busboy": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/cors": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "8.5.0",
|
"version": "8.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
|
||||||
@@ -520,6 +548,77 @@
|
|||||||
"fast-deep-equal": "^3.1.3"
|
"fast-deep-equal": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/reply-from": {
|
||||||
|
"version": "9.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz",
|
||||||
|
"integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/error": "^3.0.0",
|
||||||
|
"end-of-stream": "^1.4.4",
|
||||||
|
"fast-content-type-parse": "^1.1.0",
|
||||||
|
"fast-querystring": "^1.0.0",
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"toad-cache": "^3.7.0",
|
||||||
|
"undici": "^5.19.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/reply-from/node_modules/undici": {
|
||||||
|
"version": "5.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
||||||
|
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/busboy": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/send": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.1",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"fast-decode-uri-component": "^1.0.1",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"mime": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/static": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/accept-negotiator": "^1.0.0",
|
||||||
|
"@fastify/send": "^2.0.0",
|
||||||
|
"content-disposition": "^0.5.3",
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"fastq": "^1.17.0",
|
||||||
|
"glob": "^10.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^5.1.2",
|
||||||
|
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||||
|
"strip-ansi": "^7.0.1",
|
||||||
|
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||||
|
"wrap-ansi": "^8.1.0",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
@@ -548,12 +647,31 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lukeed/ms": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgjs/parseargs": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tsconfig/node10": {
|
"node_modules/@tsconfig/node10": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||||
@@ -593,6 +711,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/yauzl": {
|
||||||
|
"version": "2.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
|
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/abstract-logging": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -674,6 +802,30 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
@@ -700,6 +852,48 @@
|
|||||||
"fastq": "^1.17.1"
|
"fastq": "^1.17.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-crc32": {
|
||||||
|
"version": "0.2.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
|
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "12.1.0",
|
"version": "12.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
@@ -709,6 +903,18 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
@@ -725,6 +931,48 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "src/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.14",
|
||||||
|
"npm": ">=6",
|
||||||
|
"yarn": ">=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||||
@@ -735,6 +983,27 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eastasianwidth": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "9.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -777,6 +1046,12 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-content-type-parse": {
|
"node_modules/fast-content-type-parse": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
||||||
@@ -891,6 +1166,15 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fd-slicer": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pend": "~1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/find-my-way": {
|
"node_modules/find-my-way": {
|
||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
||||||
@@ -905,6 +1189,22 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/foreground-child": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"signal-exit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -929,6 +1229,12 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fuzzysort": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.13.0",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||||
@@ -942,6 +1248,48 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"inherits": "2.0.4",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "2.0.1",
|
||||||
|
"toidentifier": "1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -951,6 +1299,36 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-ref-resolver": {
|
"node_modules/json-schema-ref-resolver": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
||||||
@@ -977,6 +1355,12 @@
|
|||||||
"set-cookie-parser": "^2.4.1"
|
"set-cookie-parser": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/make-error": {
|
"node_modules/make-error": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||||
@@ -984,6 +1368,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mnemonist": {
|
"node_modules/mnemonist": {
|
||||||
"version": "0.39.6",
|
"version": "0.39.6",
|
||||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
||||||
@@ -1008,6 +1428,52 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
|
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pend": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "9.14.0",
|
"version": "9.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||||
@@ -1139,6 +1605,26 @@
|
|||||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-regex2": {
|
"node_modules/safe-regex2": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
||||||
@@ -1181,6 +1667,45 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonic-boom": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||||
@@ -1199,6 +1724,111 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eastasianwidth": "^0.2.0",
|
||||||
|
"emoji-regex": "^9.2.2",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs": {
|
||||||
|
"name": "string-width",
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs": {
|
||||||
|
"name": "strip-ansi",
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thread-stream": {
|
"node_modules/thread-stream": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||||
@@ -1217,6 +1847,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-node": {
|
"node_modules/ts-node": {
|
||||||
"version": "10.9.2",
|
"version": "10.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||||
@@ -1296,6 +1935,15 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "6.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||||
|
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -1310,6 +1958,128 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.1.0",
|
||||||
|
"string-width": "^5.0.1",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs": {
|
||||||
|
"name": "wrap-ansi",
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yauzl": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-crc32": "~0.2.3",
|
||||||
|
"fd-slicer": "~1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yn": {
|
"node_modules/yn": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
"codenomad": "dist/bin.js"
|
"codenomad": "dist/bin.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||||
"build:ui": "npm run build --prefix ../ui",
|
"build:ui": "npm run build --prefix ../ui",
|
||||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||||
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -32,9 +32,11 @@
|
|||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/yauzl": "^2.10.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
|
|||||||
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const cliRoot = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
|
||||||
|
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
|
||||||
|
|
||||||
|
if (!existsSync(sourceDir)) {
|
||||||
|
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(targetDir, { recursive: true, force: true })
|
||||||
|
mkdirSync(targetDir, { recursive: true })
|
||||||
|
cpSync(sourceDir, targetDir, { recursive: true })
|
||||||
|
|
||||||
|
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)
|
||||||
@@ -167,7 +167,6 @@ export type WorkspaceEventType =
|
|||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
| "app.releaseAvailable"
|
|
||||||
|
|
||||||
export type WorkspaceEventPayload =
|
export type WorkspaceEventPayload =
|
||||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||||
@@ -180,7 +179,6 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
|
|
||||||
|
|
||||||
export interface NetworkAddress {
|
export interface NetworkAddress {
|
||||||
ip: string
|
ip: string
|
||||||
@@ -198,6 +196,19 @@ export interface LatestReleaseInfo {
|
|||||||
notes?: string
|
notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UiMeta {
|
||||||
|
version?: string
|
||||||
|
source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportMeta {
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerMeta {
|
export interface ServerMeta {
|
||||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||||
httpBaseUrl: string
|
httpBaseUrl: string
|
||||||
@@ -215,8 +226,9 @@ export interface ServerMeta {
|
|||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
/** Reachable addresses for this server, external first. */
|
/** Reachable addresses for this server, external first. */
|
||||||
addresses: NetworkAddress[]
|
addresses: NetworkAddress[]
|
||||||
/** Optional metadata about the most recent public release. */
|
serverVersion?: string
|
||||||
latestRelease?: LatestReleaseInfo
|
ui?: UiMeta
|
||||||
|
support?: SupportMeta
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||||
|
|||||||
175
packages/server/src/auth/auth-store.ts
Normal file
175
packages/server/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||||
|
|
||||||
|
export interface AuthFile {
|
||||||
|
version: 1
|
||||||
|
username: string
|
||||||
|
password: PasswordHashRecord
|
||||||
|
userProvided: boolean
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
username: string
|
||||||
|
passwordUserProvided: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthStore {
|
||||||
|
private cachedFile: AuthFile | null = null
|
||||||
|
private overrideAuth: AuthFile | null = null
|
||||||
|
private bootstrapUsername: string | null = null
|
||||||
|
|
||||||
|
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
|
||||||
|
|
||||||
|
getAuthFilePath() {
|
||||||
|
return this.authFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): AuthFile | null {
|
||||||
|
if (this.overrideAuth) {
|
||||||
|
return this.overrideAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cachedFile) {
|
||||||
|
return this.cachedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.authFilePath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(this.authFilePath, "utf-8")
|
||||||
|
const parsed = JSON.parse(raw) as AuthFile
|
||||||
|
if (!parsed || parsed.version !== 1) {
|
||||||
|
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
this.cachedFile = parsed
|
||||||
|
return parsed
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureInitialized(params: {
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
allowBootstrapWithoutPassword: boolean
|
||||||
|
}): void {
|
||||||
|
const password = params.password?.trim()
|
||||||
|
if (password) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const runtime: AuthFile = {
|
||||||
|
version: 1,
|
||||||
|
username: params.username,
|
||||||
|
password: hashPassword(password),
|
||||||
|
userProvided: true,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
this.overrideAuth = runtime
|
||||||
|
this.cachedFile = null
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.load()
|
||||||
|
if (existing) {
|
||||||
|
if (existing.username !== params.username) {
|
||||||
|
// Keep existing username unless explicitly overridden later.
|
||||||
|
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
|
||||||
|
}
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.allowBootstrapWithoutPassword) {
|
||||||
|
this.bootstrapUsername = params.username
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCredentials(username: string, password: string): boolean {
|
||||||
|
const auth = this.load()
|
||||||
|
if (!auth) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username !== auth.username) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyPassword(password, auth.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
|
||||||
|
if (this.overrideAuth) {
|
||||||
|
throw new Error(
|
||||||
|
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.load()
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
if (!this.bootstrapUsername) {
|
||||||
|
throw new Error("Auth is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: AuthFile = {
|
||||||
|
version: 1,
|
||||||
|
username: this.bootstrapUsername,
|
||||||
|
password: hashPassword(params.password),
|
||||||
|
userProvided: params.markUserProvided,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persist(created)
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
return { username: created.username, passwordUserProvided: created.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: AuthFile = {
|
||||||
|
...current,
|
||||||
|
password: hashPassword(params.password),
|
||||||
|
userProvided: params.markUserProvided,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persist(next)
|
||||||
|
return { username: next.username, passwordUserProvided: next.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): AuthStatus {
|
||||||
|
const current = this.load()
|
||||||
|
if (current) {
|
||||||
|
return { username: current.username, passwordUserProvided: current.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.bootstrapUsername) {
|
||||||
|
return { username: this.bootstrapUsername, passwordUserProvided: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Auth is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(auth: AuthFile) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
|
||||||
|
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
|
||||||
|
this.cachedFile = auth
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
packages/server/src/auth/http-auth.ts
Normal file
38
packages/server/src/auth/http-auth.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
|
||||||
|
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
if (!header) return result
|
||||||
|
|
||||||
|
const parts = header.split(";")
|
||||||
|
for (const part of parts) {
|
||||||
|
const index = part.indexOf("=")
|
||||||
|
if (index < 0) continue
|
||||||
|
const key = part.slice(0, index).trim()
|
||||||
|
const value = part.slice(index + 1).trim()
|
||||||
|
if (!key) continue
|
||||||
|
result[key] = decodeURIComponent(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
|
||||||
|
if (!remoteAddress) return false
|
||||||
|
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
|
||||||
|
if (remoteAddress === "::ffff:127.0.0.1") return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wantsHtml(request: FastifyRequest): boolean {
|
||||||
|
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
|
||||||
|
return accept.includes("text/html") || accept.includes("application/xhtml")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
113
packages/server/src/auth/manager.ts
Normal file
113
packages/server/src/auth/manager.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
import path from "path"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { AuthStore } from "./auth-store"
|
||||||
|
import { TokenManager } from "./token-manager"
|
||||||
|
import { SessionManager } from "./session-manager"
|
||||||
|
import { isLoopbackAddress, parseCookies } from "./http-auth"
|
||||||
|
|
||||||
|
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
|
||||||
|
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
|
||||||
|
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
|
||||||
|
|
||||||
|
export interface AuthManagerInit {
|
||||||
|
configPath: string
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
generateToken: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthManager {
|
||||||
|
private readonly authStore: AuthStore
|
||||||
|
private readonly tokenManager: TokenManager | null
|
||||||
|
private readonly sessionManager = new SessionManager()
|
||||||
|
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
|
||||||
|
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||||
|
const authFilePath = resolveAuthFilePath(init.configPath)
|
||||||
|
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
|
||||||
|
|
||||||
|
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
|
||||||
|
this.authStore.ensureInitialized({
|
||||||
|
username: init.username,
|
||||||
|
password: init.password,
|
||||||
|
allowBootstrapWithoutPassword: init.generateToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookieName(): string {
|
||||||
|
return this.cookieName
|
||||||
|
}
|
||||||
|
|
||||||
|
isTokenBootstrapEnabled(): boolean {
|
||||||
|
return Boolean(this.tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
issueBootstrapToken(): string | null {
|
||||||
|
if (!this.tokenManager) return null
|
||||||
|
return this.tokenManager.generate()
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeBootstrapToken(token: string): boolean {
|
||||||
|
if (!this.tokenManager) return false
|
||||||
|
return this.tokenManager.consume(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateLogin(username: string, password: string): boolean {
|
||||||
|
return this.authStore.validateCredentials(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(username: string) {
|
||||||
|
return this.sessionManager.createSession(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return this.authStore.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassword(password: string) {
|
||||||
|
return this.authStore.setPassword({ password, markUserProvided: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||||
|
return isLoopbackAddress(request.socket.remoteAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||||
|
const cookies = parseCookies(request.headers.cookie)
|
||||||
|
const sessionId = cookies[this.cookieName]
|
||||||
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
|
if (!session) return null
|
||||||
|
return { username: session.username, sessionId: session.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionCookie(reply: FastifyReply, sessionId: string) {
|
||||||
|
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSessionCookie(reply: FastifyReply) {
|
||||||
|
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthFilePath(configPath: string) {
|
||||||
|
const resolvedConfigPath = resolvePath(configPath)
|
||||||
|
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(filePath: string) {
|
||||||
|
if (filePath.startsWith("~/")) {
|
||||||
|
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||||
|
}
|
||||||
|
return path.resolve(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
|
||||||
|
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||||
|
if (options?.maxAgeSeconds !== undefined) {
|
||||||
|
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||||
|
}
|
||||||
|
return parts.join("; ")
|
||||||
|
}
|
||||||
49
packages/server/src/auth/password-hash.ts
Normal file
49
packages/server/src/auth/password-hash.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface PasswordHashRecord {
|
||||||
|
algorithm: "scrypt"
|
||||||
|
saltBase64: string
|
||||||
|
hashBase64: string
|
||||||
|
keyLength: number
|
||||||
|
params: {
|
||||||
|
N: number
|
||||||
|
r: number
|
||||||
|
p: number
|
||||||
|
maxmem: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCRYPT_PARAMS = {
|
||||||
|
N: 16384,
|
||||||
|
r: 8,
|
||||||
|
p: 1,
|
||||||
|
maxmem: 32 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password: string): PasswordHashRecord {
|
||||||
|
const salt = crypto.randomBytes(16)
|
||||||
|
const params = DEFAULT_SCRYPT_PARAMS
|
||||||
|
const keyLength = 64
|
||||||
|
const derived = crypto.scryptSync(password, salt, keyLength, params)
|
||||||
|
return {
|
||||||
|
algorithm: "scrypt",
|
||||||
|
saltBase64: salt.toString("base64"),
|
||||||
|
hashBase64: Buffer.from(derived).toString("base64"),
|
||||||
|
keyLength,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
|
||||||
|
if (record.algorithm !== "scrypt") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = Buffer.from(record.saltBase64, "base64")
|
||||||
|
const expected = Buffer.from(record.hashBase64, "base64")
|
||||||
|
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
|
||||||
|
if (expected.length !== derived.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return crypto.timingSafeEqual(expected, Buffer.from(derived))
|
||||||
|
}
|
||||||
23
packages/server/src/auth/session-manager.ts
Normal file
23
packages/server/src/auth/session-manager.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string
|
||||||
|
createdAt: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionManager {
|
||||||
|
private sessions = new Map<string, SessionInfo>()
|
||||||
|
|
||||||
|
createSession(username: string): SessionInfo {
|
||||||
|
const id = crypto.randomBytes(32).toString("base64url")
|
||||||
|
const info: SessionInfo = { id, createdAt: Date.now(), username }
|
||||||
|
this.sessions.set(id, info)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(id: string | undefined): SessionInfo | undefined {
|
||||||
|
if (!id) return undefined
|
||||||
|
return this.sessions.get(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/server/src/auth/token-manager.ts
Normal file
32
packages/server/src/auth/token-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface BootstrapToken {
|
||||||
|
token: string
|
||||||
|
createdAt: number
|
||||||
|
consumed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenManager {
|
||||||
|
private token: BootstrapToken | null = null
|
||||||
|
|
||||||
|
constructor(private readonly ttlMs: number) {}
|
||||||
|
|
||||||
|
generate(): string {
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url")
|
||||||
|
this.token = { token, createdAt: Date.now(), consumed: false }
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(token: string): boolean {
|
||||||
|
if (!this.token) return false
|
||||||
|
if (this.token.consumed) return false
|
||||||
|
if (Date.now() - this.token.createdAt > this.ttlMs) return false
|
||||||
|
if (token !== this.token.token) return false
|
||||||
|
this.token.consumed = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
peek(): string | null {
|
||||||
|
return this.token?.token ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { createWriteStream, existsSync, promises as fs } from "fs"
|
import { createWriteStream, existsSync, promises as fs } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
@@ -60,10 +60,13 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
||||||
|
|
||||||
const child = spawn("bash", ["-c", command], {
|
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
|
||||||
|
|
||||||
|
const child = spawn(shellCommand, shellArgs, {
|
||||||
cwd: workspace.path,
|
cwd: workspace.path,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
|
...spawnOptions,
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on("exit", () => {
|
child.on("exit", () => {
|
||||||
@@ -274,7 +277,15 @@ export class BackgroundProcessManager {
|
|||||||
const pid = child.pid
|
const pid = child.pid
|
||||||
if (!pid) return
|
if (!pid) return
|
||||||
|
|
||||||
if (process.platform !== "win32") {
|
if (process.platform === "win32") {
|
||||||
|
const args = this.buildWindowsTaskkillArgs(pid, signal)
|
||||||
|
try {
|
||||||
|
spawnSync("taskkill", args, { stdio: "ignore" })
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Fall back to killing the direct child.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, signal)
|
process.kill(-pid, signal)
|
||||||
return
|
return
|
||||||
@@ -321,6 +332,30 @@ export class BackgroundProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const comspec = process.env.ComSpec || "cmd.exe"
|
||||||
|
return {
|
||||||
|
shellCommand: comspec,
|
||||||
|
shellArgs: ["/d", "/s", "/c", command],
|
||||||
|
spawnOptions: { windowsVerbatimArguments: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep bash for macOS/Linux.
|
||||||
|
return { shellCommand: "bash", shellArgs: ["-c", command] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
|
||||||
|
// Default to graceful termination (no /F), then force kill when we escalate.
|
||||||
|
const force = signal === "SIGKILL"
|
||||||
|
const args = ["/PID", String(pid), "/T"]
|
||||||
|
if (force) {
|
||||||
|
args.push("/F")
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||||
if (code === null) return "stopped"
|
if (code === null) return "stopped"
|
||||||
if (code === 0) return "stopped"
|
if (code === 0) return "stopped"
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
BinaryUpdateRequest,
|
BinaryUpdateRequest,
|
||||||
BinaryValidationResult,
|
BinaryValidationResult,
|
||||||
} from "../api-types"
|
} from "../api-types"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
import { ConfigStore } from "./store"
|
import { ConfigStore } from "./store"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import type { ConfigFile } from "./schema"
|
import type { ConfigFile } from "./schema"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
import { buildSpawnSpec } from "../workspaces/runtime"
|
||||||
|
|
||||||
export class BinaryRegistry {
|
export class BinaryRegistry {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -135,8 +137,42 @@ export class BinaryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||||
// TODO: call actual binary -v check.
|
const inputPath = record.path
|
||||||
return { valid: true, version: record.version }
|
if (!inputPath) {
|
||||||
|
return { valid: false, error: "Missing binary path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = buildSpawnSpec(inputPath, ["--version"])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(spec.command, spec.args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { valid: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim()
|
||||||
|
const stdout = result.stdout?.trim()
|
||||||
|
const combined = stderr || stdout
|
||||||
|
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||||
|
return { valid: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = (result.stdout ?? "").trim()
|
||||||
|
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
||||||
|
const normalized = firstLine?.trim()
|
||||||
|
|
||||||
|
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||||
|
const version = versionMatch?.[1]
|
||||||
|
|
||||||
|
return { valid: true, version }
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildFallbackRecord(path: string): BinaryRecord {
|
private buildFallbackRecord(path: string): BinaryRecord {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", handler)
|
this.on("instance.eventStatus", handler)
|
||||||
this.on("app.releaseAvailable", handler)
|
|
||||||
return () => {
|
return () => {
|
||||||
this.off("workspace.created", handler)
|
this.off("workspace.created", handler)
|
||||||
this.off("workspace.started", handler)
|
this.off("workspace.started", handler)
|
||||||
@@ -41,7 +40,6 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", handler)
|
this.off("instance.eventStatus", handler)
|
||||||
this.off("app.releaseAvailable", handler)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { InstanceStore } from "./storage/instance-store"
|
|||||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { startReleaseMonitor } from "./releases/release-monitor"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -36,7 +37,13 @@ interface CliOptions {
|
|||||||
logDestination?: string
|
logDestination?: string
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServer?: string
|
uiDevServer?: string
|
||||||
|
uiAutoUpdate: boolean
|
||||||
|
uiNoUpdate: boolean
|
||||||
|
uiManifestUrl?: string
|
||||||
launch: boolean
|
launch: boolean
|
||||||
|
authUsername: string
|
||||||
|
authPassword?: string
|
||||||
|
generateToken: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PORT = 9898
|
const DEFAULT_PORT = 9898
|
||||||
@@ -62,7 +69,21 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||||
|
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
|
||||||
|
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
|
||||||
|
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
|
||||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||||
|
.addOption(
|
||||||
|
new Option("--username <username>", "Username for server authentication")
|
||||||
|
.env("CODENOMAD_SERVER_USERNAME")
|
||||||
|
.default(DEFAULT_AUTH_USERNAME),
|
||||||
|
)
|
||||||
|
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||||
|
.addOption(
|
||||||
|
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||||
|
.env("CODENOMAD_GENERATE_TOKEN")
|
||||||
|
.default(false),
|
||||||
|
)
|
||||||
|
|
||||||
program.parse(argv, { from: "user" })
|
program.parse(argv, { from: "user" })
|
||||||
const parsed = program.opts<{
|
const parsed = program.opts<{
|
||||||
@@ -76,13 +97,22 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
logDestination?: string
|
logDestination?: string
|
||||||
uiDir: string
|
uiDir: string
|
||||||
uiDevServer?: string
|
uiDevServer?: string
|
||||||
|
uiNoUpdate?: boolean
|
||||||
|
uiAutoUpdate?: string
|
||||||
|
uiManifestUrl?: string
|
||||||
launch?: boolean
|
launch?: boolean
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
generateToken?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||||
|
|
||||||
const normalizedHost = resolveHost(parsed.host)
|
const normalizedHost = resolveHost(parsed.host)
|
||||||
|
|
||||||
|
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
||||||
|
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: parsed.port,
|
port: parsed.port,
|
||||||
host: normalizedHost,
|
host: normalizedHost,
|
||||||
@@ -93,7 +123,13 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
logDestination: parsed.logDestination,
|
logDestination: parsed.logDestination,
|
||||||
uiStaticDir: parsed.uiDir,
|
uiStaticDir: parsed.uiDir,
|
||||||
uiDevServer: parsed.uiDevServer,
|
uiDevServer: parsed.uiDevServer,
|
||||||
|
uiAutoUpdate,
|
||||||
|
uiNoUpdate: Boolean(parsed.uiNoUpdate),
|
||||||
|
uiManifestUrl: parsed.uiManifestUrl,
|
||||||
launch: Boolean(parsed.launch),
|
launch: Boolean(parsed.launch),
|
||||||
|
authUsername: parsed.username,
|
||||||
|
authPassword: parsed.password,
|
||||||
|
generateToken: Boolean(parsed.generateToken),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +142,22 @@ function parsePort(input: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveHost(input: string | undefined): string {
|
function resolveHost(input: string | undefined): string {
|
||||||
if (input && input.trim() === "0.0.0.0") {
|
const trimmed = input?.trim()
|
||||||
|
if (!trimmed) return DEFAULT_HOST
|
||||||
|
|
||||||
|
if (trimmed === "0.0.0.0") {
|
||||||
return "0.0.0.0"
|
return "0.0.0.0"
|
||||||
}
|
}
|
||||||
return DEFAULT_HOST
|
|
||||||
|
if (trimmed === "localhost") {
|
||||||
|
return DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function programHasArg(argv: string[], flag: string): boolean {
|
||||||
|
return argv.includes(flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -119,21 +167,45 @@ async function main() {
|
|||||||
const configLogger = logger.child({ component: "config" })
|
const configLogger = logger.child({ component: "config" })
|
||||||
const eventLogger = logger.child({ component: "events" })
|
const eventLogger = logger.child({ component: "events" })
|
||||||
|
|
||||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
const logOptions = {
|
||||||
|
...options,
|
||||||
|
authPassword: options.authPassword ? "[REDACTED]" : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
|
||||||
|
|
||||||
const eventBus = new EventBus(eventLogger)
|
const eventBus = new EventBus(eventLogger)
|
||||||
|
|
||||||
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
|
||||||
const serverMeta: ServerMeta = {
|
const serverMeta: ServerMeta = {
|
||||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||||
eventsUrl: `/api/events`,
|
eventsUrl: `/api/events`,
|
||||||
host: options.host,
|
host: options.host,
|
||||||
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||||
port: options.port,
|
port: options.port,
|
||||||
hostLabel: options.host,
|
hostLabel: options.host,
|
||||||
workspaceRoot: options.rootDir,
|
workspaceRoot: options.rootDir,
|
||||||
addresses: [],
|
addresses: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authManager = new AuthManager(
|
||||||
|
{
|
||||||
|
configPath: options.configPath,
|
||||||
|
username: options.authUsername,
|
||||||
|
password: options.authPassword,
|
||||||
|
generateToken: options.generateToken,
|
||||||
|
},
|
||||||
|
logger.child({ component: "auth" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (options.generateToken) {
|
||||||
|
const token = authManager.issueBootstrapToken()
|
||||||
|
if (token) {
|
||||||
|
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
@@ -152,19 +224,36 @@ async function main() {
|
|||||||
logger: logger.child({ component: "instance-events" }),
|
logger: logger.child({ component: "instance-events" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const releaseMonitor = startReleaseMonitor({
|
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
|
||||||
currentVersion: packageJson.version,
|
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
|
||||||
logger: logger.child({ component: "release-monitor" }),
|
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
|
||||||
onUpdate: (release) => {
|
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
|
||||||
if (release) {
|
|
||||||
serverMeta.latestRelease = release
|
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
|
||||||
eventBus.publish({ type: "app.releaseAvailable", release })
|
|
||||||
} else {
|
const uiResolution = await resolveUi({
|
||||||
delete serverMeta.latestRelease
|
serverVersion: packageJson.version,
|
||||||
}
|
bundledUiDir: DEFAULT_UI_STATIC_DIR,
|
||||||
},
|
autoUpdate: autoUpdateEnabled,
|
||||||
|
overrideUiDir: uiDirOverride,
|
||||||
|
uiDevServerUrl: options.uiDevServer,
|
||||||
|
manifestUrl: options.uiManifestUrl,
|
||||||
|
logger: logger.child({ component: "ui" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
serverMeta.serverVersion = packageJson.version
|
||||||
|
serverMeta.ui = {
|
||||||
|
version: uiResolution.uiVersion,
|
||||||
|
source: uiResolution.source,
|
||||||
|
}
|
||||||
|
serverMeta.support = {
|
||||||
|
supported: uiResolution.supported,
|
||||||
|
message: uiResolution.message,
|
||||||
|
latestServerVersion: uiResolution.latestServerVersion,
|
||||||
|
latestServerUrl: uiResolution.latestServerUrl,
|
||||||
|
minServerVersion: uiResolution.minServerVersion,
|
||||||
|
}
|
||||||
|
|
||||||
const server = createHttpServer({
|
const server = createHttpServer({
|
||||||
host: options.host,
|
host: options.host,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
@@ -175,8 +264,9 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
uiStaticDir: options.uiStaticDir,
|
authManager,
|
||||||
uiDevServerUrl: options.uiDevServer,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,7 +302,7 @@ async function main() {
|
|||||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseMonitor.stop()
|
// no-op: remote UI manifest replaces GitHub release monitor
|
||||||
|
|
||||||
logger.info("Exiting process")
|
logger.info("Exiting process")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
|||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
|
import type { AuthManager } from "../auth/manager"
|
||||||
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
host: string
|
host: string
|
||||||
@@ -34,6 +37,7 @@ interface HttpServerDeps {
|
|||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -88,8 +92,42 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||||
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
origin: true,
|
origin: (origin, cb) => {
|
||||||
|
if (!origin) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let selfOrigin: string | null = null
|
||||||
|
try {
|
||||||
|
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
|
||||||
|
} catch {
|
||||||
|
selfOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selfOrigin && origin === selfOrigin) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedDevOrigins.has(origin)) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||||
|
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
cb(null, false)
|
||||||
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,6 +147,76 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
logger: deps.logger.child({ component: "background-processes" }),
|
logger: deps.logger.child({ component: "background-processes" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
|
app.addHook("preHandler", (request, reply, done) => {
|
||||||
|
const rawUrl = request.raw.url ?? request.url
|
||||||
|
const pathname = (rawUrl.split("?")[0] ?? "").trim()
|
||||||
|
|
||||||
|
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
|
||||||
|
const publicPagePaths = new Set(["/login"])
|
||||||
|
if (deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
publicPagePaths.add("/auth/token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
|
||||||
|
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||||
|
if (requiresAuthForApi && !session) {
|
||||||
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||||
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||||
|
if (pluginMatch) {
|
||||||
|
const workspaceId = pluginMatch[1]
|
||||||
|
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
const provided = Array.isArray(request.headers.authorization)
|
||||||
|
? request.headers.authorization[0]
|
||||||
|
: request.headers.authorization
|
||||||
|
|
||||||
|
if (expected && provided && provided === expected) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendUnauthorized(request, reply)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.uiDevServerUrl) {
|
||||||
|
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiDir = deps.uiStaticDir
|
||||||
|
const indexPath = path.join(uiDir, "index.html")
|
||||||
|
if (uiDir && fs.existsSync(indexPath)) {
|
||||||
|
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(404).send({ message: "UI bundle missing" })
|
||||||
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
@@ -125,9 +233,9 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
|
|
||||||
if (deps.uiDevServerUrl) {
|
if (deps.uiDevServerUrl) {
|
||||||
setupDevProxy(app, deps.uiDevServerUrl)
|
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
|
||||||
} else {
|
} else {
|
||||||
setupStaticUi(app, deps.uiStaticDir)
|
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -175,13 +283,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||||
|
|
||||||
deps.serverMeta.httpBaseUrl = serverUrl
|
deps.serverMeta.httpBaseUrl = serverUrl
|
||||||
deps.serverMeta.host = deps.host
|
deps.serverMeta.host = deps.host
|
||||||
deps.serverMeta.port = actualPort
|
deps.serverMeta.port = actualPort
|
||||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
|
||||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||||
|
|
||||||
@@ -260,6 +368,7 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||||
|
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
|
||||||
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||||
if (logger.isLevelEnabled("trace")) {
|
if (logger.isLevelEnabled("trace")) {
|
||||||
@@ -267,6 +376,12 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return reply.from(targetUrl, {
|
return reply.from(targetUrl, {
|
||||||
|
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||||
|
if (instanceAuthHeader) {
|
||||||
|
headers.authorization = instanceAuthHeader
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
},
|
||||||
onError: (proxyReply, { error }) => {
|
onError: (proxyReply, { error }) => {
|
||||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||||
if (!proxyReply.sent) {
|
if (!proxyReply.sent) {
|
||||||
@@ -284,7 +399,7 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|||||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||||
if (!uiDir) {
|
if (!uiDir) {
|
||||||
app.log.warn("UI static directory not provided; API endpoints only")
|
app.log.warn("UI static directory not provided; API endpoints only")
|
||||||
return
|
return
|
||||||
@@ -310,6 +425,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromRequest(request)
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (fs.existsSync(indexPath)) {
|
if (fs.existsSync(indexPath)) {
|
||||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||||
} else {
|
} else {
|
||||||
@@ -318,7 +439,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
|
||||||
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
||||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const url = request.raw.url ?? ""
|
const url = request.raw.url ?? ""
|
||||||
@@ -326,6 +447,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
|||||||
reply.code(404).send({ message: "Not Found" })
|
reply.code(404).send({ message: "Not Found" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromRequest(request)
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void proxyToDevServer(request, reply, upstreamBase)
|
void proxyToDevServer(request, reply, upstreamBase)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
134
packages/server/src/server/routes/auth-pages/login.html
Normal file
134
packages/server/src/server/routes/auth-pages/login.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CodeNomad Login</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||||
|
background: #0b0b0f;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: #0f0f16;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0;
|
||||||
|
background: #4c6fff;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
||||||
|
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" autocomplete="current-password" value="" />
|
||||||
|
|
||||||
|
<button id="submit" type="button">Continue</button>
|
||||||
|
<div id="error" class="error" style="display: none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id)
|
||||||
|
const errorEl = $("error")
|
||||||
|
const showError = (msg) => {
|
||||||
|
errorEl.textContent = msg
|
||||||
|
errorEl.style.display = "block"
|
||||||
|
}
|
||||||
|
const hideError = () => {
|
||||||
|
errorEl.textContent = ""
|
||||||
|
errorEl.style.display = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
hideError()
|
||||||
|
const username = $("username").value.trim()
|
||||||
|
const password = $("password").value
|
||||||
|
if (!username || !password) {
|
||||||
|
showError("Username and password are required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = ""
|
||||||
|
try {
|
||||||
|
const json = await res.json()
|
||||||
|
message = json && json.error ? String(json.error) : ""
|
||||||
|
} catch {
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
showError(message || `Login failed (${res.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.location.href = "/"
|
||||||
|
} catch (e) {
|
||||||
|
showError(e && e.message ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("submit").addEventListener("click", submit)
|
||||||
|
$("password").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") submit()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CodeNomad</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||||
|
background: #0b0b0f;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Connecting…</h1>
|
||||||
|
<p>Finalizing local authentication.</p>
|
||||||
|
<div id="error" class="error" style="display: none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const token = (location.hash || "").replace(/^#/, "").trim()
|
||||||
|
const errorEl = document.getElementById("error")
|
||||||
|
const showError = (msg) => {
|
||||||
|
errorEl.textContent = msg
|
||||||
|
errorEl.style.display = "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!token) {
|
||||||
|
showError("Missing bootstrap token.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = ""
|
||||||
|
try {
|
||||||
|
const json = await res.json()
|
||||||
|
message = json && json.error ? String(json.error) : ""
|
||||||
|
} catch {
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
showError(message || `Token exchange failed (${res.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace("/")
|
||||||
|
} catch (e) {
|
||||||
|
showError(e && e.message ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
157
packages/server/src/server/routes/auth.ts
Normal file
157
packages/server/src/server/routes/auth.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import fs from "fs"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { AuthManager } from "../../auth/manager"
|
||||||
|
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
authManager: AuthManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const TokenSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PasswordSchema = z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
})
|
||||||
|
|
||||||
|
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
||||||
|
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
||||||
|
|
||||||
|
let cachedLoginTemplate: string | null = null
|
||||||
|
let cachedTokenTemplate: string | null = null
|
||||||
|
|
||||||
|
function readTemplate(url: URL, cache: string | null): string {
|
||||||
|
if (cache) return cache
|
||||||
|
const content = fs.readFileSync(url, "utf-8")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginHtml(defaultUsername: string): string {
|
||||||
|
if (!cachedLoginTemplate) {
|
||||||
|
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedUsername = escapeHtml(defaultUsername)
|
||||||
|
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenHtml(): string {
|
||||||
|
if (!cachedTokenTemplate) {
|
||||||
|
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedTokenTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/login", async (_request, reply) => {
|
||||||
|
const status = deps.authManager.getStatus()
|
||||||
|
reply.type("text/html").send(getLoginHtml(status.username))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/auth/token", async (request, reply) => {
|
||||||
|
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.type("text/html").send(getTokenHtml())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/auth/status", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.send({ authenticated: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (request, reply) => {
|
||||||
|
const body = LoginSchema.parse(request.body ?? {})
|
||||||
|
const ok = deps.authManager.validateLogin(body.username, body.password)
|
||||||
|
if (!ok) {
|
||||||
|
reply.code(401).send({ error: "Invalid credentials" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = deps.authManager.createSession(body.username)
|
||||||
|
deps.authManager.setSessionCookie(reply, session.id)
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/token", async (request, reply) => {
|
||||||
|
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = TokenSchema.parse(request.body ?? {})
|
||||||
|
const ok = deps.authManager.consumeBootstrapToken(body.token)
|
||||||
|
if (!ok) {
|
||||||
|
reply.code(401).send({ error: "Invalid token" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = deps.authManager.getStatus().username
|
||||||
|
const session = deps.authManager.createSession(username)
|
||||||
|
deps.authManager.setSessionCookie(reply, session.id)
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", async (_request, reply) => {
|
||||||
|
deps.authManager.clearSessionCookie(reply)
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/password", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = PasswordSchema.parse(request.body ?? {})
|
||||||
|
try {
|
||||||
|
const status = deps.authManager.setPassword(body.password)
|
||||||
|
reply.send({ ok: true, ...status })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
reply.code(409).type("text/plain").send(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value.replace(/[&<>"]/g, (char) => {
|
||||||
|
switch (char) {
|
||||||
|
case "&":
|
||||||
|
return "&"
|
||||||
|
case "<":
|
||||||
|
return "<"
|
||||||
|
case ">":
|
||||||
|
return ">"
|
||||||
|
case '"':
|
||||||
|
return """
|
||||||
|
default:
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
|||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
port,
|
port,
|
||||||
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||||
addresses,
|
addresses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,10 @@ function resolvePort(meta: ServerMeta): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(host: string): boolean {
|
||||||
|
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||||
const interfaces = os.networkInterfaces()
|
const interfaces = os.networkInterfaces()
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
|||||||
535
packages/server/src/ui/remote-ui.ts
Normal file
535
packages/server/src/ui/remote-ui.ts
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
import { createHash } from "crypto"
|
||||||
|
import fs from "fs"
|
||||||
|
import { promises as fsp } from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
import { fetch } from "undici"
|
||||||
|
import yauzl from "yauzl"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
export interface RemoteUiManifest {
|
||||||
|
minServerVersion: string
|
||||||
|
latestUIVersion: string
|
||||||
|
uiPackageURL: string
|
||||||
|
sha256: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UiSource = "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||||
|
|
||||||
|
export interface UiResolution {
|
||||||
|
uiStaticDir?: string
|
||||||
|
uiDevServerUrl?: string
|
||||||
|
source: UiSource
|
||||||
|
uiVersion?: string
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteUiOptions {
|
||||||
|
serverVersion: string
|
||||||
|
bundledUiDir: string
|
||||||
|
autoUpdate: boolean
|
||||||
|
overrideUiDir?: string
|
||||||
|
uiDevServerUrl?: string
|
||||||
|
manifestUrl?: string
|
||||||
|
configDir?: string
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MANIFEST_URL = "https://ui.codenomad.neuralnomads.ai/version.json"
|
||||||
|
|
||||||
|
const MANIFEST_TIMEOUT_MS = 5_000
|
||||||
|
const ZIP_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution> {
|
||||||
|
const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL
|
||||||
|
|
||||||
|
if (options.uiDevServerUrl) {
|
||||||
|
return {
|
||||||
|
uiDevServerUrl: options.uiDevServerUrl,
|
||||||
|
source: "dev-proxy",
|
||||||
|
supported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.overrideUiDir) {
|
||||||
|
const resolved = await resolveStaticUiDir(options.overrideUiDir)
|
||||||
|
return {
|
||||||
|
uiStaticDir: resolved ?? options.overrideUiDir,
|
||||||
|
source: "override",
|
||||||
|
uiVersion: await readUiVersion(resolved ?? options.overrideUiDir),
|
||||||
|
supported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiRoot = resolveUiCacheRoot(options.configDir)
|
||||||
|
const currentDir = path.join(uiRoot, "current")
|
||||||
|
const previousDir = path.join(uiRoot, "previous")
|
||||||
|
|
||||||
|
if (!options.autoUpdate) {
|
||||||
|
const local = await resolveStaticUiDir(currentDir)
|
||||||
|
if (local) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: local,
|
||||||
|
source: "downloaded",
|
||||||
|
uiVersion: await readUiVersion(local),
|
||||||
|
supported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundled = await resolveStaticUiDir(options.bundledUiDir)
|
||||||
|
return {
|
||||||
|
uiStaticDir: bundled ?? options.bundledUiDir,
|
||||||
|
source: bundled ? "bundled" : "missing",
|
||||||
|
uiVersion: bundled ? await readUiVersion(bundled) : undefined,
|
||||||
|
supported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: RemoteUiManifest | null = null
|
||||||
|
try {
|
||||||
|
manifest = await fetchManifest(manifestUrl, options.logger)
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.debug({ err: error }, "Remote UI manifest unavailable; using cached/bundled UI")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = compareSemverCore(options.serverVersion, manifest.minServerVersion) >= 0
|
||||||
|
if (!supported) {
|
||||||
|
const message = "Upgrade App to use latest features"
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: false,
|
||||||
|
message,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVersion = await readUiVersion(currentDir)
|
||||||
|
if (currentVersion && currentVersion === manifest.latestUIVersion) {
|
||||||
|
const currentResolved = await resolveStaticUiDir(currentDir)
|
||||||
|
if (currentResolved) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: currentResolved,
|
||||||
|
source: "downloaded",
|
||||||
|
uiVersion: currentVersion,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await installRemoteUi({
|
||||||
|
manifest,
|
||||||
|
uiRoot,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
logger: options.logger,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.warn({ err: error }, "Failed to install remote UI; falling back")
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = await resolveStaticUiDir(currentDir)
|
||||||
|
if (installed) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: installed,
|
||||||
|
source: "downloaded",
|
||||||
|
uiVersion: await readUiVersion(installed),
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUiCacheRoot(configDir?: string): string {
|
||||||
|
if (configDir) {
|
||||||
|
return path.join(configDir, "ui")
|
||||||
|
}
|
||||||
|
return path.join(os.homedir(), ".config", "codenomad", "ui")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFromCacheOrBundled(args: {
|
||||||
|
logger: Logger
|
||||||
|
bundledUiDir: string
|
||||||
|
currentDir: string
|
||||||
|
previousDir: string
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
}): Promise<UiResolution> {
|
||||||
|
const currentResolved = await resolveStaticUiDir(args.currentDir)
|
||||||
|
if (currentResolved) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: currentResolved,
|
||||||
|
source: "downloaded",
|
||||||
|
uiVersion: await readUiVersion(currentResolved),
|
||||||
|
supported: args.supported,
|
||||||
|
message: args.message,
|
||||||
|
latestServerVersion: args.latestServerVersion,
|
||||||
|
latestServerUrl: args.latestServerUrl,
|
||||||
|
minServerVersion: args.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousResolved = await resolveStaticUiDir(args.previousDir)
|
||||||
|
if (previousResolved) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: previousResolved,
|
||||||
|
source: "previous",
|
||||||
|
uiVersion: await readUiVersion(previousResolved),
|
||||||
|
supported: args.supported,
|
||||||
|
message: args.message,
|
||||||
|
latestServerVersion: args.latestServerVersion,
|
||||||
|
latestServerUrl: args.latestServerUrl,
|
||||||
|
minServerVersion: args.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
|
||||||
|
if (bundledResolved) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: bundledResolved,
|
||||||
|
source: "bundled",
|
||||||
|
uiVersion: await readUiVersion(bundledResolved),
|
||||||
|
supported: args.supported,
|
||||||
|
message: args.message,
|
||||||
|
latestServerVersion: args.latestServerVersion,
|
||||||
|
latestServerUrl: args.latestServerUrl,
|
||||||
|
minServerVersion: args.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.logger.warn({ bundledUiDir: args.bundledUiDir }, "No UI assets found")
|
||||||
|
return {
|
||||||
|
uiStaticDir: args.bundledUiDir,
|
||||||
|
source: "missing",
|
||||||
|
supported: args.supported,
|
||||||
|
message: args.message,
|
||||||
|
latestServerVersion: args.latestServerVersion,
|
||||||
|
latestServerUrl: args.latestServerUrl,
|
||||||
|
minServerVersion: args.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const indexPath = path.join(uiDir, "index.html")
|
||||||
|
await fsp.access(indexPath, fs.constants.R_OK)
|
||||||
|
return uiDir
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UiVersionFile {
|
||||||
|
uiVersion?: string
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUiVersion(uiDir: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await fsp.readFile(path.join(uiDir, "ui-version.json"), "utf-8")
|
||||||
|
const parsed = JSON.parse(content) as UiVersionFile
|
||||||
|
return parsed.uiVersion ?? parsed.version
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchManifest(url: string, logger: Logger): Promise<RemoteUiManifest> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Manifest responded with ${response.status}`)
|
||||||
|
}
|
||||||
|
const json = (await response.json()) as RemoteUiManifest
|
||||||
|
validateManifest(json)
|
||||||
|
return json
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug({ err: error, url }, "Failed to fetch remote UI manifest")
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifest(manifest: RemoteUiManifest) {
|
||||||
|
const required: Array<keyof RemoteUiManifest> = ["minServerVersion", "latestUIVersion", "uiPackageURL", "sha256"]
|
||||||
|
for (const key of required) {
|
||||||
|
const value = manifest[key]
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
throw new Error(`Manifest missing ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^https:\/\//i.test(manifest.uiPackageURL)) {
|
||||||
|
throw new Error("uiPackageURL must be https")
|
||||||
|
}
|
||||||
|
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256.trim())) {
|
||||||
|
throw new Error("sha256 must be 64 hex chars")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRemoteUi(args: {
|
||||||
|
manifest: RemoteUiManifest
|
||||||
|
uiRoot: string
|
||||||
|
currentDir: string
|
||||||
|
previousDir: string
|
||||||
|
logger: Logger
|
||||||
|
}) {
|
||||||
|
await fsp.mkdir(args.uiRoot, { recursive: true })
|
||||||
|
|
||||||
|
const tmpDir = path.join(args.uiRoot, `tmp-${Date.now()}`)
|
||||||
|
const zipPath = path.join(args.uiRoot, `ui-${args.manifest.latestUIVersion}.zip`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadFile(args.manifest.uiPackageURL, zipPath, args.logger)
|
||||||
|
const digest = await sha256File(zipPath)
|
||||||
|
if (digest.toLowerCase() !== args.manifest.sha256.toLowerCase()) {
|
||||||
|
throw new Error(`sha256 mismatch for UI zip (expected ${args.manifest.sha256}, got ${digest})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await extractZip(zipPath, tmpDir)
|
||||||
|
|
||||||
|
const indexPath = path.join(tmpDir, "index.html")
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
throw new Error("Extracted UI missing index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
await rotateDirs({ currentDir: args.currentDir, previousDir: args.previousDir, logger: args.logger })
|
||||||
|
|
||||||
|
fs.rmSync(args.currentDir, { recursive: true, force: true })
|
||||||
|
fs.renameSync(tmpDir, args.currentDir)
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
fs.rmSync(zipPath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateDirs(args: { currentDir: string; previousDir: string; logger: Logger }) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(args.previousDir)) {
|
||||||
|
fs.rmSync(args.previousDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
if (fs.existsSync(args.currentDir)) {
|
||||||
|
fs.renameSync(args.currentDir, args.previousDir)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
args.logger.warn({ err: error }, "Failed to rotate UI cache directories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(url: string, targetPath: string, logger: Logger) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ZIP_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/octet-stream",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`UI zip download failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||||
|
const fileStream = fs.createWriteStream(targetPath)
|
||||||
|
|
||||||
|
const body = response.body
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("UI zip response missing body")
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeStream = Readable.fromWeb(body as any)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
nodeStream.pipe(fileStream)
|
||||||
|
nodeStream.on("error", reject)
|
||||||
|
fileStream.on("error", reject)
|
||||||
|
fileStream.on("finish", () => resolve())
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug({ url, targetPath }, "Downloaded remote UI bundle")
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256File(filePath: string): Promise<string> {
|
||||||
|
const hash = createHash("sha256")
|
||||||
|
const stream = fs.createReadStream(filePath)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stream.on("data", (chunk) => hash.update(chunk))
|
||||||
|
stream.on("error", reject)
|
||||||
|
stream.on("end", () => resolve())
|
||||||
|
})
|
||||||
|
return hash.digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
|
||||||
|
await fsp.mkdir(targetDir, { recursive: true })
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipfile) => {
|
||||||
|
if (openErr || !zipfile) {
|
||||||
|
reject(openErr ?? new Error("Unable to open zip"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = path.resolve(targetDir)
|
||||||
|
|
||||||
|
const closeWithError = (error: unknown) => {
|
||||||
|
try {
|
||||||
|
zipfile.close()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
zipfile.readEntry()
|
||||||
|
|
||||||
|
zipfile.on("entry", (entry) => {
|
||||||
|
// Normalize and guard against zip-slip.
|
||||||
|
const entryPath = entry.fileName.replace(/\\/g, "/")
|
||||||
|
|
||||||
|
const segments = entryPath.split("/").filter(Boolean)
|
||||||
|
if (segments.some((segment: string) => segment === "..") || path.isAbsolute(entryPath)) {
|
||||||
|
closeWithError(new Error(`Invalid zip entry path: ${entry.fileName}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = path.resolve(targetDir, entryPath)
|
||||||
|
if (!destination.startsWith(root + path.sep) && destination !== root) {
|
||||||
|
closeWithError(new Error(`Zip entry escapes target dir: ${entry.fileName}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectory = entry.fileName.endsWith("/")
|
||||||
|
|
||||||
|
if (isDirectory) {
|
||||||
|
fsp
|
||||||
|
.mkdir(destination, { recursive: true })
|
||||||
|
.then(() => zipfile.readEntry())
|
||||||
|
.catch((error) => closeWithError(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fsp
|
||||||
|
.mkdir(path.dirname(destination), { recursive: true })
|
||||||
|
.then(() => {
|
||||||
|
zipfile.openReadStream(entry, (streamErr, readStream) => {
|
||||||
|
if (streamErr || !readStream) {
|
||||||
|
closeWithError(streamErr ?? new Error("Unable to read zip entry"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeStream = fs.createWriteStream(destination)
|
||||||
|
const cleanup = (error?: unknown) => {
|
||||||
|
readStream.destroy()
|
||||||
|
writeStream.destroy()
|
||||||
|
if (error) {
|
||||||
|
closeWithError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readStream.on("error", cleanup)
|
||||||
|
writeStream.on("error", cleanup)
|
||||||
|
writeStream.on("finish", () => zipfile.readEntry())
|
||||||
|
|
||||||
|
readStream.pipe(writeStream)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => closeWithError(error))
|
||||||
|
})
|
||||||
|
|
||||||
|
zipfile.on("end", () => {
|
||||||
|
zipfile.close()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
zipfile.on("error", (error) => closeWithError(error))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemverCore(a: string, b: string): number {
|
||||||
|
const pa = parseSemverCore(a)
|
||||||
|
const pb = parseSemverCore(b)
|
||||||
|
if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
|
||||||
|
if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
|
||||||
|
if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSemverCore(value: string): { major: number; minor: number; patch: number } {
|
||||||
|
const core = value.trim().replace(/^v/i, "").split("-", 1)[0] ?? "0.0.0"
|
||||||
|
const parts = core.split(".")
|
||||||
|
const parsePart = (input: string | undefined) => {
|
||||||
|
const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
major: parsePart(parts[0]),
|
||||||
|
minor: parsePart(parts[1]),
|
||||||
|
patch: parsePart(parts[2]),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,8 +96,15 @@ export class InstanceEventBridge {
|
|||||||
|
|
||||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||||
|
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
if (authHeader) {
|
||||||
|
headers["Authorization"] = authHeader
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { Accept: "text/event-stream" },
|
headers,
|
||||||
signal,
|
signal,
|
||||||
dispatcher: STREAM_AGENT,
|
dispatcher: STREAM_AGENT,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
|
|||||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||||
|
import {
|
||||||
|
buildOpencodeBasicAuthHeader,
|
||||||
|
DEFAULT_OPENCODE_USERNAME,
|
||||||
|
generateOpencodeServerPassword,
|
||||||
|
OPENCODE_SERVER_PASSWORD_ENV,
|
||||||
|
OPENCODE_SERVER_USERNAME_ENV,
|
||||||
|
} from "./opencode-auth"
|
||||||
|
|
||||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||||
|
|
||||||
@@ -29,6 +36,7 @@ export class WorkspaceManager {
|
|||||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||||
private readonly runtime: WorkspaceRuntime
|
private readonly runtime: WorkspaceRuntime
|
||||||
private readonly opencodeConfigDir: string
|
private readonly opencodeConfigDir: string
|
||||||
|
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
|
||||||
|
|
||||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||||
@@ -47,6 +55,10 @@ export class WorkspaceManager {
|
|||||||
return this.workspaces.get(id)?.port
|
return this.workspaces.get(id)?.port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInstanceAuthorizationHeader(id: string): string | undefined {
|
||||||
|
return this.opencodeAuth.get(id)?.authorization
|
||||||
|
}
|
||||||
|
|
||||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||||
const workspace = this.requireWorkspace(workspaceId)
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
@@ -106,11 +118,22 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
const preferences = this.options.configStore.get().preferences ?? {}
|
const preferences = this.options.configStore.get().preferences ?? {}
|
||||||
const userEnvironment = preferences.environmentVariables ?? {}
|
const userEnvironment = preferences.environmentVariables ?? {}
|
||||||
|
|
||||||
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||||
|
const opencodePassword = generateOpencodeServerPassword()
|
||||||
|
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
|
||||||
|
if (!authorization) {
|
||||||
|
throw new Error("Failed to build OpenCode auth header")
|
||||||
|
}
|
||||||
|
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
|
||||||
|
|
||||||
const environment = {
|
const environment = {
|
||||||
...userEnvironment,
|
...userEnvironment,
|
||||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||||
CODENOMAD_INSTANCE_ID: id,
|
CODENOMAD_INSTANCE_ID: id,
|
||||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||||
|
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||||
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +177,7 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.workspaces.delete(id)
|
this.workspaces.delete(id)
|
||||||
|
this.opencodeAuth.delete(id)
|
||||||
clearWorkspaceSearchCache(workspace.path)
|
clearWorkspaceSearchCache(workspace.path)
|
||||||
if (!wasRunning) {
|
if (!wasRunning) {
|
||||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||||
@@ -174,6 +198,7 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.workspaces.clear()
|
this.workspaces.clear()
|
||||||
|
this.opencodeAuth.clear()
|
||||||
this.options.logger.info("All workspaces cleared")
|
this.options.logger.info("All workspaces cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,13 +225,15 @@ export class WorkspaceManager {
|
|||||||
try {
|
try {
|
||||||
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||||
if (result.status === 0 && result.stdout) {
|
if (result.status === 0 && result.stdout) {
|
||||||
const resolved = result.stdout
|
const candidates = result.stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.find((line) => line.length > 0)
|
.filter((line) => line.length > 0)
|
||||||
|
.filter((line) => !/^INFO:/i.test(line))
|
||||||
|
|
||||||
if (resolved) {
|
if (candidates.length > 0) {
|
||||||
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
const resolved = this.pickBinaryCandidate(candidates)
|
||||||
|
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
@@ -219,6 +246,23 @@ export class WorkspaceManager {
|
|||||||
return identifier
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pickBinaryCandidate(candidates: string[]): string {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return candidates[0] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
|
||||||
|
|
||||||
|
for (const ext of extensionPreference) {
|
||||||
|
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
|
||||||
|
if (match) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||||
if (!resolvedPath) {
|
if (!resolvedPath) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -317,7 +361,13 @@ export class WorkspaceManager {
|
|||||||
const url = `http://127.0.0.1:${port}/project/current`
|
const url = `http://127.0.0.1:${port}/project/current`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url)
|
const headers: Record<string, string> = {}
|
||||||
|
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
|
||||||
|
if (authHeader) {
|
||||||
|
headers["Authorization"] = authHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = `health probe returned HTTP ${response.status}`
|
const reason = `health probe returned HTTP ${response.status}`
|
||||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||||
@@ -408,6 +458,8 @@ export class WorkspaceManager {
|
|||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
if (!workspace) return
|
if (!workspace) return
|
||||||
|
|
||||||
|
this.opencodeAuth.delete(workspaceId)
|
||||||
|
|
||||||
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
||||||
|
|
||||||
workspace.pid = undefined
|
workspace.pid = undefined
|
||||||
|
|||||||
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import crypto from "node:crypto"
|
||||||
|
|
||||||
|
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
|
||||||
|
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
|
||||||
|
|
||||||
|
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
|
||||||
|
|
||||||
|
export function generateOpencodeServerPassword(): string {
|
||||||
|
return crypto.randomBytes(32).toString("base64url")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
|
||||||
|
const username = params.username
|
||||||
|
const password = params.password
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||||
|
return `Basic ${token}`
|
||||||
|
}
|
||||||
@@ -5,6 +5,55 @@ import { EventBus } from "../events/bus"
|
|||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
|
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||||
|
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||||
|
|
||||||
|
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return { command: binaryPath, args, options: {} as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(binaryPath).toLowerCase()
|
||||||
|
|
||||||
|
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||||
|
const comspec = process.env.ComSpec || "cmd.exe"
|
||||||
|
// cmd.exe requires the full command as a single string.
|
||||||
|
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||||
|
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: comspec,
|
||||||
|
args: ["/d", "/s", "/c", commandLine],
|
||||||
|
options: { windowsVerbatimArguments: true } as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||||
|
// powershell.exe ships with Windows. (pwsh may not.)
|
||||||
|
return {
|
||||||
|
command: "powershell.exe",
|
||||||
|
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||||
|
options: {} as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: binaryPath, args, options: {} as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||||
|
|
||||||
|
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||||
|
const redacted: Record<string, string | undefined> = {}
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
redacted[key] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
|
||||||
|
}
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
|
||||||
interface LaunchOptions {
|
interface LaunchOptions {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
folder: string
|
folder: string
|
||||||
@@ -59,22 +108,25 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const commandLine = [options.binaryPath, ...args].join(" ")
|
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||||
|
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
folder: options.folder,
|
folder: options.folder,
|
||||||
binary: options.binaryPath,
|
binary: options.binaryPath,
|
||||||
args,
|
spawnCommand: spec.command,
|
||||||
|
spawnArgs: spec.args,
|
||||||
commandLine,
|
commandLine,
|
||||||
env,
|
env: redactEnvironment(env),
|
||||||
},
|
},
|
||||||
"Launching OpenCode process",
|
"Launching OpenCode process",
|
||||||
)
|
)
|
||||||
const child = spawn(options.binaryPath, args, {
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: options.folder,
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
...spec.options,
|
||||||
})
|
})
|
||||||
|
|
||||||
const managed: ManagedProcess = { child, requestedStop: false }
|
const managed: ManagedProcess = { child, requestedStop: false }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
|
|||||||
@@ -166,6 +166,44 @@ function copyServerArtifacts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripNodeModuleBins() {
|
||||||
|
const root = path.join(serverDest, "node_modules")
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [root]
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()
|
||||||
|
if (!current) break
|
||||||
|
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(current, entry.name)
|
||||||
|
if (entry.name === ".bin") {
|
||||||
|
fs.rmSync(full, { recursive: true, force: true })
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyUiLoadingAssets() {
|
function copyUiLoadingAssets() {
|
||||||
const loadingSource = path.join(uiDist, "loading.html")
|
const loadingSource = path.join(uiDist, "loading.html")
|
||||||
const assetsSource = path.join(uiDist, "assets")
|
const assetsSource = path.join(uiDist, "assets")
|
||||||
@@ -192,4 +230,5 @@ ensureServerDependencies()
|
|||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ use std::collections::VecDeque;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
@@ -31,9 +32,15 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||||
|
|
||||||
fn navigate_main(app: &AppHandle, url: &str) {
|
fn navigate_main(app: &AppHandle, url: &str) {
|
||||||
if let Some(win) = app.webview_windows().get("main") {
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
log_line(&format!("navigating main to {url}"));
|
let mut display = url.to_string();
|
||||||
|
if let Some(hash_index) = display.find('#') {
|
||||||
|
display.replace_range(hash_index + 1.., "[REDACTED]");
|
||||||
|
}
|
||||||
|
log_line(&format!("navigating main to {display}"));
|
||||||
if let Ok(parsed) = Url::parse(url) {
|
if let Ok(parsed) = Url::parse(url) {
|
||||||
let _ = win.navigate(parsed);
|
let _ = win.navigate(parsed);
|
||||||
} else {
|
} else {
|
||||||
@@ -44,6 +51,85 @@ fn navigate_main(app: &AppHandle, url: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{name}=");
|
||||||
|
let cookie_kv = set_cookie.split(';').next()?.trim();
|
||||||
|
if !cookie_kv.starts_with(&prefix) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = cookie_kv.trim_start_matches(&prefix).trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||||
|
let parsed = Url::parse(base_url)?;
|
||||||
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
|
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||||
|
|
||||||
|
// This is only used for local bootstrap; we assume plain HTTP.
|
||||||
|
let mut stream = TcpStream::connect((host, port))?;
|
||||||
|
|
||||||
|
let body = format!("{{\"token\":\"{}\"}}", token);
|
||||||
|
let request = format!(
|
||||||
|
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.as_bytes().len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
stream.write_all(request.as_bytes())?;
|
||||||
|
stream.flush()?;
|
||||||
|
|
||||||
|
let mut response = String::new();
|
||||||
|
stream.read_to_string(&mut response)?;
|
||||||
|
|
||||||
|
let (raw_headers, _rest) = response
|
||||||
|
.split_once("\r\n\r\n")
|
||||||
|
.or_else(|| response.split_once("\n\n"))
|
||||||
|
.unwrap_or((response.as_str(), ""));
|
||||||
|
|
||||||
|
let mut lines = raw_headers.lines();
|
||||||
|
let status_line = lines.next().unwrap_or("");
|
||||||
|
if !status_line.contains(" 200 ") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
// handle case-insensitive header name
|
||||||
|
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||||
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||||
|
return Ok(Some(session_id));
|
||||||
|
}
|
||||||
|
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||||
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||||
|
return Ok(Some(session_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||||
|
let parsed = Url::parse(base_url)?;
|
||||||
|
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||||
|
|
||||||
|
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||||
|
.domain(domain)
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(tauri::webview::cookie::SameSite::Lax)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
|
win.set_cookie(cookie)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -139,6 +225,7 @@ pub struct CliProcessManager {
|
|||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliProcessManager {
|
impl CliProcessManager {
|
||||||
@@ -147,6 +234,7 @@ impl CliProcessManager {
|
|||||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
child: Arc::new(Mutex::new(None)),
|
child: Arc::new(Mutex::new(None)),
|
||||||
ready: Arc::new(AtomicBool::new(false)),
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +242,7 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("start requested (dev={dev})"));
|
log_line(&format!("start requested (dev={dev})"));
|
||||||
self.stop()?;
|
self.stop()?;
|
||||||
self.ready.store(false, Ordering::SeqCst);
|
self.ready.store(false, Ordering::SeqCst);
|
||||||
|
*self.bootstrap_token.lock() = None;
|
||||||
{
|
{
|
||||||
let mut status = self.status.lock();
|
let mut status = self.status.lock();
|
||||||
status.state = CliState::Starting;
|
status.state = CliState::Starting;
|
||||||
@@ -167,8 +256,9 @@ impl CliProcessManager {
|
|||||||
let status_arc = self.status.clone();
|
let status_arc = self.status.clone();
|
||||||
let child_arc = self.child.clone();
|
let child_arc = self.child.clone();
|
||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
|
let token_arc = self.bootstrap_token.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
|
||||||
log_line(&format!("cli spawn failed: {err}"));
|
log_line(&format!("cli spawn failed: {err}"));
|
||||||
let mut locked = status_arc.lock();
|
let mut locked = status_arc.lock();
|
||||||
locked.state = CliState::Error;
|
locked.state = CliState::Error;
|
||||||
@@ -237,6 +327,7 @@ impl CliProcessManager {
|
|||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child_holder: Arc<Mutex<Option<Child>>>,
|
child_holder: Arc<Mutex<Option<Child>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
dev: bool,
|
dev: bool,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
log_line("resolving CLI entry");
|
log_line("resolving CLI entry");
|
||||||
@@ -318,8 +409,10 @@ impl CliProcessManager {
|
|||||||
let status_clone = status.clone();
|
let status_clone = status.clone();
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
|
let token_clone = bootstrap_token.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
|
||||||
let stdout = child_clone
|
let stdout = child_clone
|
||||||
.lock()
|
.lock()
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -332,10 +425,10 @@ impl CliProcessManager {
|
|||||||
.map(BufReader::new);
|
.map(BufReader::new);
|
||||||
|
|
||||||
if let Some(reader) = stdout {
|
if let Some(reader) = stdout {
|
||||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||||
}
|
}
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,10 +500,12 @@ impl CliProcessManager {
|
|||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||||
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
@@ -419,6 +514,17 @@ impl CliProcessManager {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let line = buffer.trim_end();
|
let line = buffer.trim_end();
|
||||||
if !line.is_empty() {
|
if !line.is_empty() {
|
||||||
|
if line.starts_with(token_prefix) {
|
||||||
|
let token = line.trim_start_matches(token_prefix).trim();
|
||||||
|
if !token.is_empty() {
|
||||||
|
let mut guard = bootstrap_token.lock();
|
||||||
|
if guard.is_none() {
|
||||||
|
*guard = Some(token.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
log_line(&format!("[cli][{}] {}", stream, line));
|
log_line(&format!("[cli][{}] {}", stream, line));
|
||||||
|
|
||||||
if ready.load(Ordering::SeqCst) {
|
if ready.load(Ordering::SeqCst) {
|
||||||
@@ -430,7 +536,7 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, port);
|
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,13 +546,13 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, port);
|
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||||
Self::mark_ready(app, status, ready, port as u16);
|
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,16 +564,46 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
fn mark_ready(
|
||||||
|
app: &AppHandle,
|
||||||
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
|
ready: &Arc<AtomicBool>,
|
||||||
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
port: u16,
|
||||||
|
) {
|
||||||
ready.store(true, Ordering::SeqCst);
|
ready.store(true, Ordering::SeqCst);
|
||||||
|
let base_url = format!("http://127.0.0.1:{port}");
|
||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
let url = format!("http://127.0.0.1:{port}");
|
|
||||||
locked.port = Some(port);
|
locked.port = Some(port);
|
||||||
locked.url = Some(url.clone());
|
locked.url = Some(base_url.clone());
|
||||||
locked.state = CliState::Ready;
|
locked.state = CliState::Ready;
|
||||||
locked.error = None;
|
locked.error = None;
|
||||||
log_line(&format!("cli ready on {url}"));
|
log_line(&format!("cli ready on {base_url}"));
|
||||||
navigate_main(app, &url);
|
|
||||||
|
let token = bootstrap_token.lock().take();
|
||||||
|
|
||||||
|
if let Some(token) = token {
|
||||||
|
match exchange_bootstrap_token(&base_url, &token) {
|
||||||
|
Ok(Some(session_id)) => {
|
||||||
|
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||||
|
log_line(&format!("failed to set session cookie: {err}"));
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
} else {
|
||||||
|
navigate_main(app, &base_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log_line("bootstrap token exchange failed (invalid token)");
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigate_main(app, &base_url);
|
||||||
|
}
|
||||||
let _ = app.emit("cli:ready", locked.clone());
|
let _ = app.emit("cli:ready", locked.clone());
|
||||||
Self::emit_status(app, &locked);
|
Self::emit_status(app, &locked);
|
||||||
}
|
}
|
||||||
@@ -551,6 +687,7 @@ impl CliEntry {
|
|||||||
host.to_string(),
|
host.to_string(),
|
||||||
"--port".to_string(),
|
"--port".to_string(),
|
||||||
"0".to_string(),
|
"0".to_string(),
|
||||||
|
"--generate-token".to_string(),
|
||||||
];
|
];
|
||||||
if dev {
|
if dev {
|
||||||
args.push("--ui-dev-server".to_string());
|
args.push("--ui-dev-server".to_string());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.6.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.1",
|
"@opencode-ai/sdk": "1.1.11",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
|
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
||||||
|
|||||||
30
packages/ui/src/components/expand-button.tsx
Normal file
30
packages/ui/src/components/expand-button.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import { Maximize2, Minimize2 } from "lucide-solid"
|
||||||
|
|
||||||
|
interface ExpandButtonProps {
|
||||||
|
expandState: () => "normal" | "expanded"
|
||||||
|
onToggleExpand: (nextState: "normal" | "expanded") => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExpandButton(props: ExpandButtonProps) {
|
||||||
|
function handleClick() {
|
||||||
|
const current = props.expandState()
|
||||||
|
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-expand-button"
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-label="Toggle chat input height"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.expandState() === "normal"}
|
||||||
|
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
<Maximize2 class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import AdvancedSettingsModal from "./advanced-settings-modal"
|
|||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
import VersionPill from "./version-pill"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -248,6 +249,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||||
|
<div class="mt-2 flex justify-center">
|
||||||
|
<VersionPill />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -875,7 +875,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||||
<SessionList
|
<SessionList
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
sessions={allInstanceSessions()}
|
|
||||||
threads={sessionThreads()}
|
threads={sessionThreads()}
|
||||||
activeSessionId={activeSessionIdForInstance()}
|
activeSessionId={activeSessionIdForInstance()}
|
||||||
onSelect={handleSessionSelect}
|
onSelect={handleSessionSelect}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import { FoldVertical } from "lucide-solid"
|
import { FoldVertical } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
@@ -82,8 +82,20 @@ interface TaskSessionLocation {
|
|||||||
parentId: string | null
|
parentId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
|
function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string): TaskSessionLocation | null {
|
||||||
if (!sessionId) return null
|
if (!sessionId) return null
|
||||||
|
|
||||||
|
if (preferredInstanceId) {
|
||||||
|
const session = sessions().get(preferredInstanceId)?.get(sessionId)
|
||||||
|
if (session) {
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
instanceId: preferredInstanceId,
|
||||||
|
parentId: session.parentId ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allSessions = sessions()
|
const allSessions = sessions()
|
||||||
for (const [instanceId, sessionMap] of allSessions) {
|
for (const [instanceId, sessionMap] of allSessions) {
|
||||||
const session = sessionMap?.get(sessionId)
|
const session = sessionMap?.get(sessionId)
|
||||||
@@ -235,16 +247,11 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const index = props.messageIndex
|
const index = props.messageIndex
|
||||||
const lastAssistantIdx = props.lastAssistantIndex()
|
const lastAssistantIdx = props.lastAssistantIndex()
|
||||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||||
const info = messageInfo()
|
|
||||||
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
// Intentionally untracked: messageInfoVersion updates should not trigger
|
||||||
const infoTimestamp =
|
// a full message block rebuild; record revision is the invalidation key.
|
||||||
typeof infoTime.completed === "number"
|
const info = untrack(messageInfo)
|
||||||
? infoTime.completed
|
|
||||||
: typeof infoTime.updated === "number"
|
|
||||||
? infoTime.updated
|
|
||||||
: infoTime.created ?? 0
|
|
||||||
const infoError = (info as { error?: { name?: string } } | undefined)?.error
|
|
||||||
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
|
|
||||||
const cacheSignature = [
|
const cacheSignature = [
|
||||||
current.id,
|
current.id,
|
||||||
current.revision,
|
current.revision,
|
||||||
@@ -252,8 +259,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
props.showThinking() ? 1 : 0,
|
props.showThinking() ? 1 : 0,
|
||||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||||
props.showUsageMetrics() ? 1 : 0,
|
props.showUsageMetrics() ? 1 : 0,
|
||||||
infoTimestamp,
|
|
||||||
infoErrorName,
|
|
||||||
].join("|")
|
].join("|")
|
||||||
|
|
||||||
const cachedBlock = sessionCache.messageBlocks.get(current.id)
|
const cachedBlock = sessionCache.messageBlocks.get(current.id)
|
||||||
@@ -447,7 +452,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const hasToolState =
|
const hasToolState =
|
||||||
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||||
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
|
||||||
const handleGoToTaskSession = (event: MouseEvent) => {
|
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
||||||
import type { PermissionRequestLike } from "../types/permission"
|
import type { PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
||||||
import { activePermissionId, getPermissionQueue } from "../stores/instances"
|
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||||
import { loadMessages, setActiveSession } from "../stores/sessions"
|
import {
|
||||||
|
activeInterruption,
|
||||||
|
getPermissionQueue,
|
||||||
|
getQuestionQueue,
|
||||||
|
getQuestionEnqueuedAtForInstance,
|
||||||
|
sendPermissionResponse,
|
||||||
|
} from "../stores/instances"
|
||||||
|
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
|
|
||||||
@@ -88,24 +95,111 @@ function resolveToolCallFromPermission(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null {
|
||||||
|
const sessionId = getQuestionSessionId(request)
|
||||||
|
const messageId = getQuestionMessageId(request)
|
||||||
|
if (!sessionId || !messageId) return null
|
||||||
|
|
||||||
|
const store = messageStoreBus.getInstance(instanceId)
|
||||||
|
if (!store) return null
|
||||||
|
|
||||||
|
const record = store.getMessage(messageId)
|
||||||
|
if (!record) return null
|
||||||
|
|
||||||
|
const callId = getQuestionCallId(request)
|
||||||
|
if (!callId) return null
|
||||||
|
|
||||||
|
for (const partId of record.partIds) {
|
||||||
|
const partRecord = record.parts?.[partId]
|
||||||
|
const part = partRecord?.data as any
|
||||||
|
if (!part || part.type !== "tool") continue
|
||||||
|
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
|
||||||
|
if (partCallId !== callId) continue
|
||||||
|
|
||||||
|
if (typeof part.id !== "string" || part.id.length === 0) continue
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
sessionId,
|
||||||
|
toolPart: part as ResolvedToolCall["toolPart"],
|
||||||
|
messageVersion: record.revision,
|
||||||
|
partVersion: partRecord?.revision ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
|
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
|
||||||
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
||||||
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
|
||||||
|
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
|
||||||
|
|
||||||
const queue = createMemo(() => getPermissionQueue(props.instanceId))
|
const setPermissionBusy = (permissionId: string, busy: boolean) => {
|
||||||
const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null)
|
setPermissionSubmitting((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (busy) next.add(permissionId)
|
||||||
|
else next.delete(permissionId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const orderedQueue = createMemo(() => {
|
const setPermissionItemError = (permissionId: string, message: string | null) => {
|
||||||
const current = queue()
|
setPermissionError((prev) => {
|
||||||
const activeId = activePermId()
|
const next = new Map(prev)
|
||||||
if (!activeId) return current
|
if (!message) next.delete(permissionId)
|
||||||
const index = current.findIndex((entry) => entry.id === activeId)
|
else next.set(permissionId, message)
|
||||||
if (index <= 0) return current
|
return next
|
||||||
const active = current[index]
|
})
|
||||||
if (!active) return current
|
}
|
||||||
return [active, ...current.slice(0, index), ...current.slice(index + 1)]
|
|
||||||
|
async function handlePermissionDecision(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
|
||||||
|
const permissionId = permission?.id
|
||||||
|
if (!permissionId) return
|
||||||
|
|
||||||
|
if (permissionSubmitting().has(permissionId)) return
|
||||||
|
|
||||||
|
setPermissionBusy(permissionId, true)
|
||||||
|
setPermissionItemError(permissionId, null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = getPermissionSessionId(permission) || ""
|
||||||
|
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
|
||||||
|
} catch (error) {
|
||||||
|
setPermissionItemError(permissionId, error instanceof Error ? error.message : "Unable to update permission")
|
||||||
|
} finally {
|
||||||
|
setPermissionBusy(permissionId, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
|
||||||
|
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
|
||||||
|
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
|
||||||
|
|
||||||
|
type InterruptionItem =
|
||||||
|
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike }
|
||||||
|
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
|
||||||
|
|
||||||
|
const orderedQueue = createMemo<InterruptionItem[]>(() => {
|
||||||
|
const permissions = permissionQueue().map((permission) => ({
|
||||||
|
kind: "permission" as const,
|
||||||
|
id: permission.id,
|
||||||
|
sessionId: getPermissionSessionId(permission) || "",
|
||||||
|
createdAt: (permission as any)?.time?.created ?? Date.now(),
|
||||||
|
payload: permission,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const questions = questionQueue().map((question) => ({
|
||||||
|
kind: "question" as const,
|
||||||
|
id: question.id,
|
||||||
|
sessionId: getQuestionSessionId(question) || "",
|
||||||
|
createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id),
|
||||||
|
payload: question,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasPermissions = createMemo(() => queue().length > 0)
|
const hasRequests = createMemo(() => orderedQueue().length > 0)
|
||||||
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@@ -122,7 +216,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.isOpen) return
|
if (!props.isOpen) return
|
||||||
if (queue().length === 0) {
|
if (orderedQueue().length === 0) {
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -145,7 +239,14 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
|
|
||||||
function handleGoToSession(sessionId: string) {
|
function handleGoToSession(sessionId: string) {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
setActiveSession(props.instanceId, sessionId)
|
|
||||||
|
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||||
|
const parentId = session?.parentId ?? session?.id
|
||||||
|
if (parentId) {
|
||||||
|
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSessionFromList(props.instanceId, sessionId)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +257,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
<div class="permission-center-modal-header">
|
<div class="permission-center-modal-header">
|
||||||
<div class="permission-center-modal-title-row">
|
<div class="permission-center-modal-title-row">
|
||||||
<h2 id="permission-center-title" class="permission-center-modal-title">
|
<h2 id="permission-center-title" class="permission-center-modal-title">
|
||||||
Permissions
|
Requests
|
||||||
</h2>
|
</h2>
|
||||||
<Show when={queue().length > 0}>
|
<Show when={orderedQueue().length > 0}>
|
||||||
<span class="permission-center-modal-count">{queue().length}</span>
|
<span class="permission-center-modal-count">{orderedQueue().length}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
|
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
|
||||||
@@ -168,16 +269,40 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="permission-center-modal-body">
|
<div class="permission-center-modal-body">
|
||||||
<Show when={hasPermissions()} fallback={<div class="permission-center-empty">No pending permissions.</div>}>
|
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
|
||||||
<div class="permission-center-list" role="list">
|
<div class="permission-center-list" role="list">
|
||||||
<For each={orderedQueue()}>
|
<For each={orderedQueue()}>
|
||||||
{(permission) => {
|
{(item) => {
|
||||||
const sessionId = getPermissionSessionId(permission) || ""
|
const isActive = () => active()?.kind === item.kind && active()?.id === item.id
|
||||||
const isActive = () => permission.id === activePermId()
|
const sessionId = () => item.sessionId
|
||||||
const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission))
|
|
||||||
|
const resolved = createMemo(() => {
|
||||||
|
if (item.kind === "permission") {
|
||||||
|
return resolveToolCallFromPermission(props.instanceId, item.payload)
|
||||||
|
}
|
||||||
|
return resolveToolCallFromQuestion(props.instanceId, item.payload)
|
||||||
|
})
|
||||||
|
|
||||||
const showFallback = () => !resolved()
|
const showFallback = () => !resolved()
|
||||||
|
|
||||||
|
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question")
|
||||||
|
|
||||||
|
const primaryTitle = () => {
|
||||||
|
if (item.kind === "permission") {
|
||||||
|
return getPermissionDisplayTitle(item.payload)
|
||||||
|
}
|
||||||
|
const first = item.payload.questions?.[0]?.question
|
||||||
|
return typeof first === "string" && first.trim().length > 0 ? first : "Question"
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondaryTitle = () => {
|
||||||
|
if (item.kind === "permission") {
|
||||||
|
return getPermissionKind(item.payload)
|
||||||
|
}
|
||||||
|
const count = item.payload.questions?.length ?? 0
|
||||||
|
return count === 1 ? "1 question" : `${count} questions`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
|
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
|
||||||
@@ -185,7 +310,8 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
>
|
>
|
||||||
<div class="permission-center-item-header">
|
<div class="permission-center-item-header">
|
||||||
<div class="permission-center-item-heading">
|
<div class="permission-center-item-heading">
|
||||||
<span class="permission-center-item-kind">{getPermissionKind(permission)}</span>
|
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
|
||||||
|
<span class="permission-center-item-kind">{secondaryTitle()}</span>
|
||||||
<Show when={isActive()}>
|
<Show when={isActive()}>
|
||||||
<span class="permission-center-item-chip">Active</span>
|
<span class="permission-center-item-chip">Active</span>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -195,7 +321,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="permission-center-item-action"
|
class="permission-center-item-action"
|
||||||
onClick={() => handleGoToSession(sessionId)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleGoToSession(sessionId())
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Go to Session
|
Go to Session
|
||||||
</button>
|
</button>
|
||||||
@@ -203,26 +332,64 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="permission-center-item-action"
|
class="permission-center-item-action"
|
||||||
disabled={loadingSession() === sessionId}
|
disabled={loadingSession() === sessionId()}
|
||||||
onClick={() => handleLoadSession(sessionId)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleLoadSession(sessionId())
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{loadingSession() === sessionId ? "Loading…" : "Load Session"}
|
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={resolved()}
|
when={resolved()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="permission-center-fallback">
|
<div class="permission-center-fallback">
|
||||||
<div class="permission-center-fallback-title">
|
<div class="permission-center-fallback-title">
|
||||||
<code>{getPermissionDisplayTitle(permission)}</code>
|
<code>{primaryTitle()}</code>
|
||||||
|
</div>
|
||||||
|
<Show when={item.kind === "permission"}>
|
||||||
|
<div class="tool-call-permission-actions">
|
||||||
|
<div class="tool-call-permission-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
|
||||||
|
>
|
||||||
|
Allow Once
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
|
||||||
|
>
|
||||||
|
Always Allow
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={permissionError().get(item.id)}>
|
||||||
|
{(err) => <div class="tool-call-permission-error">{err()}</div>}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.kind !== "permission"}>
|
||||||
|
<div class="permission-center-fallback-hint">Load session for more information.</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-center-fallback-hint">Load session for more information.</div>
|
}
|
||||||
</div>
|
>
|
||||||
}
|
|
||||||
>
|
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolCall={data().toolPart}
|
toolCall={data().toolPart}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Show, createMemo, type Component } from "solid-js"
|
import { Show, createMemo, type Component } from "solid-js"
|
||||||
import { ShieldAlert } from "lucide-solid"
|
import { ShieldAlert } from "lucide-solid"
|
||||||
import { getPermissionQueueLength } from "../stores/instances"
|
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
|
||||||
|
|
||||||
interface PermissionNotificationBannerProps {
|
interface PermissionNotificationBannerProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -8,15 +8,21 @@ interface PermissionNotificationBannerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
||||||
const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId))
|
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
|
||||||
const hasPermissions = createMemo(() => queueLength() > 0)
|
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
|
||||||
|
const queueLength = createMemo(() => permissionCount() + questionCount())
|
||||||
|
const hasRequests = createMemo(() => queueLength() > 0)
|
||||||
const label = createMemo(() => {
|
const label = createMemo(() => {
|
||||||
const count = queueLength()
|
const total = queueLength()
|
||||||
return `${count} permission${count === 1 ? "" : "s"} pending approval`
|
const parts: string[] = []
|
||||||
|
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
|
||||||
|
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`)
|
||||||
|
const detail = parts.length ? ` (${parts.join(", ")})` : ""
|
||||||
|
return `${total} pending request${total === 1 ? "" : "s"}${detail}`
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={hasPermissions()}>
|
<Show when={hasRequests()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="permission-center-trigger"
|
class="permission-center-trigger"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
|
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
|
||||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||||
import UnifiedPicker from "./unified-picker"
|
import UnifiedPicker from "./unified-picker"
|
||||||
|
import ExpandButton from "./expand-button"
|
||||||
import { addToHistory, getHistory } from "../stores/message-history"
|
import { addToHistory, getHistory } from "../stores/message-history"
|
||||||
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
@@ -46,9 +47,16 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const [pasteCount, setPasteCount] = createSignal(0)
|
const [pasteCount, setPasteCount] = createSignal(0)
|
||||||
const [imageCount, setImageCount] = createSignal(0)
|
const [imageCount, setImageCount] = createSignal(0)
|
||||||
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
||||||
|
const [expandState, setExpandState] = createSignal<"normal" | "expanded">("normal")
|
||||||
const SELECTION_INSERT_MAX_LENGTH = 2000
|
const SELECTION_INSERT_MAX_LENGTH = 2000
|
||||||
let textareaRef: HTMLTextAreaElement | undefined
|
let textareaRef: HTMLTextAreaElement | undefined
|
||||||
let containerRef: HTMLDivElement | undefined
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (mode() === "shell") {
|
||||||
|
return "Run a shell command (Esc to exit)..."
|
||||||
|
}
|
||||||
|
return "Type your message, @file, @agent, or paste images and text..."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -596,6 +604,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setExpandState("normal")
|
||||||
clearPrompt()
|
clearPrompt()
|
||||||
|
|
||||||
// Ignore attachments for slash commands, but keep them for next prompt.
|
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||||
@@ -615,7 +624,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
// Record attempted slash commands even if execution fails.
|
// Record attempted slash commands even if execution fails.
|
||||||
void refreshHistory()
|
void refreshHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isShellMode) {
|
if (isShellMode) {
|
||||||
if (props.onRunShell) {
|
if (props.onRunShell) {
|
||||||
@@ -642,7 +651,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
textareaRef?.focus()
|
textareaRef?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusTextareaEnd() {
|
function focusTextareaEnd() {
|
||||||
if (!textareaRef) return
|
if (!textareaRef) return
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -652,7 +661,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
textareaRef.focus()
|
textareaRef.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseHistory(force = false) {
|
function canUseHistory(force = false) {
|
||||||
if (force) return true
|
if (force) return true
|
||||||
if (showPicker()) return false
|
if (showPicker()) return false
|
||||||
@@ -660,29 +669,29 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (!textarea) return false
|
if (!textarea) return false
|
||||||
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPreviousHistory(force = false) {
|
function selectPreviousHistory(force = false) {
|
||||||
const entries = history()
|
const entries = history()
|
||||||
if (entries.length === 0) return false
|
if (entries.length === 0) return false
|
||||||
if (!canUseHistory(force)) return false
|
if (!canUseHistory(force)) return false
|
||||||
|
|
||||||
if (historyIndex() === -1) {
|
if (historyIndex() === -1) {
|
||||||
setHistoryDraft(prompt())
|
setHistoryDraft(prompt())
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
||||||
setHistoryIndex(newIndex)
|
setHistoryIndex(newIndex)
|
||||||
setPrompt(entries[newIndex])
|
setPrompt(entries[newIndex])
|
||||||
focusTextareaEnd()
|
focusTextareaEnd()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectNextHistory(force = false) {
|
function selectNextHistory(force = false) {
|
||||||
const entries = history()
|
const entries = history()
|
||||||
if (entries.length === 0) return false
|
if (entries.length === 0) return false
|
||||||
if (!canUseHistory(force)) return false
|
if (!canUseHistory(force)) return false
|
||||||
if (historyIndex() === -1) return false
|
if (historyIndex() === -1) return false
|
||||||
|
|
||||||
const newIndex = historyIndex() - 1
|
const newIndex = historyIndex() - 1
|
||||||
if (newIndex >= 0) {
|
if (newIndex >= 0) {
|
||||||
setHistoryIndex(newIndex)
|
setHistoryIndex(newIndex)
|
||||||
@@ -696,12 +705,18 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
focusTextareaEnd()
|
focusTextareaEnd()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAbort() {
|
function handleAbort() {
|
||||||
if (!props.onAbortSession || !props.isSessionBusy) return
|
if (!props.onAbortSession || !props.isSessionBusy) return
|
||||||
void props.onAbortSession()
|
void props.onAbortSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleExpandToggle(nextState: "normal" | "expanded") {
|
||||||
|
setExpandState(nextState)
|
||||||
|
// Keep focus on textarea
|
||||||
|
textareaRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
|
|
||||||
const target = e.target as HTMLTextAreaElement
|
const target = e.target as HTMLTextAreaElement
|
||||||
@@ -765,9 +780,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
item:
|
item:
|
||||||
| { type: "agent"; agent: Agent }
|
| { type: "agent"; agent: Agent }
|
||||||
| {
|
| {
|
||||||
type: "file"
|
type: "file"
|
||||||
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
||||||
}
|
}
|
||||||
| { type: "command"; command: SDKCommand },
|
| { type: "command"; command: SDKCommand },
|
||||||
) {
|
) {
|
||||||
if (item.type === "command") {
|
if (item.type === "command") {
|
||||||
@@ -829,7 +844,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const currentPrompt = prompt()
|
const currentPrompt = prompt()
|
||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
const cursorPos = textareaRef?.selectionStart || 0
|
const cursorPos = textareaRef?.selectionStart || 0
|
||||||
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath
|
const folderMention =
|
||||||
|
relativePath === "." || relativePath === ""
|
||||||
|
? "/"
|
||||||
|
: relativePath.replace(/\/+$/, "") + "/"
|
||||||
|
|
||||||
if (pos !== null) {
|
if (pos !== null) {
|
||||||
const before = currentPrompt.substring(0, pos + 1)
|
const before = currentPrompt.substring(0, pos + 1)
|
||||||
@@ -873,7 +891,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (pos !== null) {
|
if (pos !== null) {
|
||||||
const before = currentPrompt.substring(0, pos)
|
const before = currentPrompt.substring(0, pos)
|
||||||
const after = currentPrompt.substring(cursorPos)
|
const after = currentPrompt.substring(cursorPos)
|
||||||
const attachmentText = `@${filename}`
|
const attachmentText = `@${normalizedPath}`
|
||||||
const newPrompt = before + attachmentText + " " + after
|
const newPrompt = before + attachmentText + " " + after
|
||||||
setPrompt(newPrompt)
|
setPrompt(newPrompt)
|
||||||
|
|
||||||
@@ -1018,18 +1036,18 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
|
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
|
||||||
|
|
||||||
const hasHistory = () => history().length > 0
|
const hasHistory = () => history().length > 0
|
||||||
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
|
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
|
||||||
const canHistoryGoNext = () => historyIndex() >= 0
|
const canHistoryGoNext = () => historyIndex() >= 0
|
||||||
|
|
||||||
const canSend = () => {
|
const canSend = () => {
|
||||||
if (props.disabled) return false
|
if (props.disabled) return false
|
||||||
const hasText = prompt().trim().length > 0
|
const hasText = prompt().trim().length > 0
|
||||||
if (mode() === "shell") return hasText
|
if (mode() === "shell") return hasText
|
||||||
return hasText || attachments().length > 0
|
return hasText || attachments().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
|
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
|
||||||
const commandHint = () => ({ key: "/", text: "Commands" })
|
const commandHint = () => ({ key: "/", text: "Commands" })
|
||||||
|
|
||||||
@@ -1040,7 +1058,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
return (
|
return (
|
||||||
<div class="prompt-input-container">
|
<div class="prompt-input-container">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
|
||||||
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
|
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
|
||||||
style={
|
style={
|
||||||
isDragging()
|
isDragging()
|
||||||
@@ -1067,188 +1084,92 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col">
|
<div class="flex flex-1 flex-col">
|
||||||
<Show when={attachments().length > 0}>
|
<div class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""}`}>
|
||||||
<div class="flex flex-wrap gap-1.5 border-b pb-2" style="border-color: var(--border-base);">
|
|
||||||
<For each={attachments()}>
|
<div class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}>
|
||||||
{(attachment) => {
|
|
||||||
const isImage = attachment.mediaType.startsWith("image/")
|
|
||||||
const textValue = attachment.source.type === "text" ? attachment.source.value : undefined
|
|
||||||
const isTextAttachment = typeof textValue === "string"
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
|
||||||
title={textValue}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={isImage}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={isTextAttachment}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={attachment.source.type === "agent"}
|
|
||||||
fallback={
|
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<img src={attachment.url} alt={attachment.filename} class="h-5 w-5 rounded object-cover" />
|
|
||||||
</Show>
|
|
||||||
<span>{isTextAttachment ? attachment.display : attachment.filename}</span>
|
|
||||||
<Show when={isTextAttachment}>
|
|
||||||
<button
|
|
||||||
onClick={() => handleExpandTextAttachment(attachment)}
|
|
||||||
class="attachment-expand"
|
|
||||||
aria-label="Expand pasted text"
|
|
||||||
title="Insert pasted text"
|
|
||||||
>
|
|
||||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 7h6v6H7z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h12v12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
|
||||||
class="attachment-remove"
|
|
||||||
aria-label="Remove attachment"
|
|
||||||
>
|
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<Show when={isImage}>
|
|
||||||
<div class="attachment-chip-preview">
|
|
||||||
<img src={attachment.url} alt={attachment.filename} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="prompt-input-field-container">
|
|
||||||
<div class="prompt-input-field">
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
|
||||||
placeholder={
|
placeholder={getPlaceholder()}
|
||||||
mode() === "shell"
|
value={prompt()}
|
||||||
? "Run a shell command (Esc to exit)..."
|
onInput={handleInput}
|
||||||
: "Type your message, @file, @agent, or paste images and text..."
|
onKeyDown={handleKeyDown}
|
||||||
}
|
onPaste={handlePaste}
|
||||||
value={prompt()}
|
onFocus={() => setIsFocused(true)}
|
||||||
onInput={handleInput}
|
onBlur={() => setIsFocused(false)}
|
||||||
onKeyDown={handleKeyDown}
|
disabled={props.disabled}
|
||||||
onPaste={handlePaste}
|
rows={expandState() === "expanded" ? 15 : 4}
|
||||||
onFocus={() => setIsFocused(true)}
|
spellcheck={false}
|
||||||
onBlur={() => setIsFocused(false)}
|
autocorrect="off"
|
||||||
disabled={props.disabled}
|
autoCapitalize="off"
|
||||||
rows={4}
|
autocomplete="off"
|
||||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
/>
|
||||||
spellcheck={false}
|
<div class="prompt-nav-buttons">
|
||||||
autocorrect="off"
|
<ExpandButton
|
||||||
autoCapitalize="off"
|
expandState={expandState}
|
||||||
autocomplete="off"
|
onToggleExpand={handleExpandToggle}
|
||||||
/>
|
/>
|
||||||
<Show when={hasHistory()}>
|
<Show when={hasHistory()}>
|
||||||
<div class="prompt-history-top">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="prompt-history-button"
|
||||||
class="prompt-history-button"
|
onClick={() => selectPreviousHistory(true)}
|
||||||
onClick={() => selectPreviousHistory(true)}
|
disabled={!canHistoryGoPrevious()}
|
||||||
disabled={!canHistoryGoPrevious()}
|
aria-label="Previous prompt"
|
||||||
aria-label="Previous prompt"
|
>
|
||||||
>
|
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-history-button"
|
||||||
|
onClick={() => selectNextHistory(true)}
|
||||||
|
disabled={!canHistoryGoNext()}
|
||||||
|
aria-label="Next prompt"
|
||||||
|
>
|
||||||
|
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="prompt-history-bottom">
|
<Show when={shouldShowOverlay()}>
|
||||||
<button
|
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
type="button"
|
<Show
|
||||||
class="prompt-history-button"
|
when={props.escapeInDebounce}
|
||||||
onClick={() => selectNextHistory(true)}
|
fallback={
|
||||||
disabled={!canHistoryGoNext()}
|
<>
|
||||||
aria-label="Next prompt"
|
|
||||||
>
|
|
||||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={shouldShowOverlay()}>
|
|
||||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
|
||||||
<Show
|
|
||||||
when={props.escapeInDebounce}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<span class="prompt-overlay-text">
|
|
||||||
<Kbd>Enter</Kbd> New line • <Kbd shortcut="cmd+enter" /> Send • <Kbd>@</Kbd> Files/agents • <Kbd>↑↓</Kbd> History
|
|
||||||
</span>
|
|
||||||
<Show when={attachments().length > 0}>
|
|
||||||
<span class="prompt-overlay-text prompt-overlay-muted">• {attachments().length} file(s) attached</span>
|
|
||||||
</Show>
|
|
||||||
<span class="prompt-overlay-text">
|
|
||||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
|
||||||
</span>
|
|
||||||
<Show when={mode() !== "shell"}>
|
|
||||||
<span class="prompt-overlay-text">
|
<span class="prompt-overlay-text">
|
||||||
• <Kbd>{commandHint().key}</Kbd> {commandHint().text}
|
<Kbd>Enter</Kbd> New line • <Kbd shortcut="cmd+enter" /> Send • <Kbd>@</Kbd> Files/agents • <Kbd>↑↓</Kbd> History
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
<Show when={attachments().length > 0}>
|
||||||
|
<span class="prompt-overlay-text prompt-overlay-muted">• {attachments().length} file(s) attached</span>
|
||||||
|
</Show>
|
||||||
|
<span class="prompt-overlay-text">
|
||||||
|
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||||
|
</span>
|
||||||
|
<Show when={mode() !== "shell"}>
|
||||||
|
<span class="prompt-overlay-text">
|
||||||
|
• <Kbd>{commandHint().key}</Kbd> {commandHint().text}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={mode() === "shell"}>
|
||||||
|
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<span class="prompt-overlay-text prompt-overlay-warning">
|
||||||
|
Press <Kbd>Esc</Kbd> again to abort session
|
||||||
|
</span>
|
||||||
<Show when={mode() === "shell"}>
|
<Show when={mode() === "shell"}>
|
||||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
}
|
</Show>
|
||||||
>
|
</div>
|
||||||
<>
|
</Show>
|
||||||
<span class="prompt-overlay-text prompt-overlay-warning">
|
</div>
|
||||||
Press <Kbd>Esc</Kbd> again to abort session
|
|
||||||
</span>
|
|
||||||
<Show when={mode() === "shell"}>
|
|
||||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<div class="prompt-input-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,10 +19,16 @@ interface RemoteAccessOverlayProps {
|
|||||||
|
|
||||||
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
|
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
|
||||||
|
const [passwordValue, setPasswordValue] = createSignal("")
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
||||||
@@ -38,9 +44,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setPasswordError(null)
|
||||||
try {
|
try {
|
||||||
const result = await serverApi.fetchServerMeta()
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(result)
|
setMeta(metaResult)
|
||||||
|
setAuthStatus(authResult)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,6 +116,36 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmitPassword = async () => {
|
||||||
|
setPasswordError(null)
|
||||||
|
|
||||||
|
const next = passwordValue()
|
||||||
|
const confirm = passwordConfirm()
|
||||||
|
|
||||||
|
if (next.trim().length < 8) {
|
||||||
|
setPasswordError("Password must be at least 8 characters.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next !== confirm) {
|
||||||
|
setPasswordError("Passwords do not match.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingPassword(true)
|
||||||
|
try {
|
||||||
|
const result = await serverApi.setServerPassword(next)
|
||||||
|
setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided })
|
||||||
|
setPasswordValue("")
|
||||||
|
setPasswordConfirm("")
|
||||||
|
setPasswordFormOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={props.open}
|
open={props.open}
|
||||||
@@ -175,6 +213,87 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="remote-section">
|
<section class="remote-section">
|
||||||
|
<div class="remote-section-heading">
|
||||||
|
<div class="remote-section-title">
|
||||||
|
<Shield class="remote-icon" />
|
||||||
|
<div>
|
||||||
|
<p class="remote-label">Server password</p>
|
||||||
|
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={authStatus() && authStatus()!.authenticated}
|
||||||
|
fallback={<div class="remote-card">Authentication status unavailable.</div>}
|
||||||
|
>
|
||||||
|
<div class="remote-card">
|
||||||
|
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p>
|
||||||
|
<p class="remote-help">
|
||||||
|
{authStatus()!.passwordUserProvided
|
||||||
|
? "A password is set for remote access."
|
||||||
|
: "No memorable password is set yet. Set one to allow remote handover logins."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPasswordFormOpen(!passwordFormOpen())
|
||||||
|
setPasswordError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{passwordFormOpen()
|
||||||
|
? "Cancel"
|
||||||
|
: authStatus()!.passwordUserProvided
|
||||||
|
? "Change password"
|
||||||
|
: "Set password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={passwordFormOpen()}>
|
||||||
|
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
|
||||||
|
<label class="text-sm font-medium text-secondary">New password</label>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
type="password"
|
||||||
|
value={passwordValue()}
|
||||||
|
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
|
||||||
|
<label class="text-sm font-medium text-secondary">Confirm password</label>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm()}
|
||||||
|
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={passwordError()}>
|
||||||
|
{(message) => <div class="remote-error" style={{ "margin-top": "10px" }}>{message()}</div>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
disabled={savingPassword()}
|
||||||
|
onClick={() => void handleSubmitPassword()}
|
||||||
|
>
|
||||||
|
{savingPassword() ? "Saving…" : "Save password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="remote-section">
|
||||||
|
|
||||||
<div class="remote-section-heading">
|
<div class="remote-section-heading">
|
||||||
<div class="remote-section-title">
|
<div class="remote-section-title">
|
||||||
<Wifi class="remote-icon" />
|
<Wifi class="remote-icon" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
||||||
import type { Session, SessionStatus } from "../types/session"
|
import type { SessionStatus } from "../types/session"
|
||||||
import type { SessionThread } from "../stores/session-state"
|
import type { SessionThread } from "../stores/session-state"
|
||||||
import { getSessionStatus } from "../stores/session-status"
|
import { getSessionStatus } from "../stores/session-status"
|
||||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
isSessionParentExpanded,
|
isSessionParentExpanded,
|
||||||
loading,
|
loading,
|
||||||
renameSession,
|
renameSession,
|
||||||
|
sessions as sessionStateSessions,
|
||||||
setActiveSessionFromList,
|
setActiveSessionFromList,
|
||||||
toggleSessionParentExpanded,
|
toggleSessionParentExpanded,
|
||||||
} from "../stores/sessions"
|
} from "../stores/sessions"
|
||||||
@@ -25,7 +26,6 @@ const log = getLogger("session")
|
|||||||
|
|
||||||
interface SessionListProps {
|
interface SessionListProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessions: Map<string, Session>
|
|
||||||
threads: SessionThread[]
|
threads: SessionThread[]
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
onSelect: (sessionId: string) => void
|
onSelect: (sessionId: string) => void
|
||||||
@@ -58,7 +58,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
const selectSession = (sessionId: string) => {
|
const selectSession = (sessionId: string) => {
|
||||||
const session = props.sessions.get(sessionId)
|
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||||
const parentId = session?.parentId ?? session?.id
|
const parentId = session?.parentId ?? session?.id
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||||
@@ -132,7 +132,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openRenameDialog = (sessionId: string) => {
|
const openRenameDialog = (sessionId: string) => {
|
||||||
const session = props.sessions.get(sessionId)
|
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
const label = session.title && session.title.trim() ? session.title : sessionId
|
const label = session.title && session.title.trim() ? session.title : sessionId
|
||||||
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||||
@@ -167,7 +167,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
expanded?: boolean
|
expanded?: boolean
|
||||||
onToggleExpand?: () => void
|
onToggleExpand?: () => void
|
||||||
}> = (rowProps) => {
|
}> = (rowProps) => {
|
||||||
const session = () => props.sessions.get(rowProps.sessionId)
|
const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId))
|
||||||
if (!session()) {
|
if (!session()) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
@@ -175,9 +175,11 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const title = () => session()?.title || "Untitled"
|
const title = () => session()?.title || "Untitled"
|
||||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||||
const statusLabel = () => formatSessionStatus(status())
|
const statusLabel = () => formatSessionStatus(status())
|
||||||
const pendingPermission = () => Boolean(session()?.pendingPermission)
|
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||||
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
|
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||||
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
|
const needsInput = () => needsPermission() || needsQuestion()
|
||||||
|
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
||||||
|
const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="session-list-item group">
|
<div class="session-list-item group">
|
||||||
@@ -224,7 +226,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||||
{pendingPermission() ? (
|
{needsInput() ? (
|
||||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
@@ -291,7 +293,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
const activeId = props.activeSessionId
|
const activeId = props.activeSessionId
|
||||||
if (!activeId || activeId === "info") return null
|
if (!activeId || activeId === "info") return null
|
||||||
|
|
||||||
const activeSession = props.sessions.get(activeId)
|
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
|
||||||
if (!activeSession) return null
|
if (!activeSession) return null
|
||||||
|
|
||||||
return activeSession.parentId ?? activeSession.id
|
return activeSession.parentId ?? activeSession.id
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Show, createMemo, createEffect, type Component } from "solid-js"
|
import { Show, For, createMemo, createEffect, type Component } from "solid-js"
|
||||||
|
import { Expand } from "lucide-solid"
|
||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import type { Attachment } from "../../types/attachment"
|
import type { Attachment } from "../../types/attachment"
|
||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
import MessageSection from "../message-section"
|
import MessageSection from "../message-section"
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
import PromptInput from "../prompt-input"
|
import PromptInput from "../prompt-input"
|
||||||
|
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
||||||
|
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||||
import { instances } from "../../stores/instances"
|
import { instances } from "../../stores/instances"
|
||||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||||
@@ -39,6 +42,62 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
if (!currentSession) return false
|
if (!currentSession) return false
|
||||||
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sessionNeedsInput = createMemo(() => {
|
||||||
|
const currentSession = session()
|
||||||
|
if (!currentSession) return false
|
||||||
|
return Boolean(currentSession.pendingPermission || (currentSession as any).pendingQuestion)
|
||||||
|
})
|
||||||
|
|
||||||
|
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||||
|
|
||||||
|
function handleExpandTextAttachment(attachment: PromptAttachment) {
|
||||||
|
if (attachment.source.type !== "text") return
|
||||||
|
|
||||||
|
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
|
||||||
|
const value = attachment.source.value
|
||||||
|
const match = attachment.display.match(/pasted #(\d+)/)
|
||||||
|
const placeholder = match ? `[pasted #${match[1]}]` : null
|
||||||
|
|
||||||
|
const currentText = textarea?.value ?? ""
|
||||||
|
|
||||||
|
let nextText = currentText
|
||||||
|
let selectionTarget: number | null = null
|
||||||
|
|
||||||
|
if (placeholder) {
|
||||||
|
const placeholderIndex = currentText.indexOf(placeholder)
|
||||||
|
if (placeholderIndex !== -1) {
|
||||||
|
nextText =
|
||||||
|
currentText.substring(0, placeholderIndex) +
|
||||||
|
value +
|
||||||
|
currentText.substring(placeholderIndex + placeholder.length)
|
||||||
|
selectionTarget = placeholderIndex + value.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextText === currentText) {
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||||
|
selectionTarget = start + value.length
|
||||||
|
} else {
|
||||||
|
nextText = currentText + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = nextText
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||||
|
textarea.focus()
|
||||||
|
if (selectionTarget !== null) {
|
||||||
|
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||||
|
}
|
||||||
|
|
||||||
let scrollToBottomHandle: (() => void) | undefined
|
let scrollToBottomHandle: (() => void) | undefined
|
||||||
let rootRef: HTMLDivElement | undefined
|
let rootRef: HTMLDivElement | undefined
|
||||||
function scheduleScrollToBottom() {
|
function scheduleScrollToBottom() {
|
||||||
@@ -224,17 +283,52 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<PromptInput
|
<Show when={attachments().length > 0}>
|
||||||
instanceId={props.instanceId}
|
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||||
instanceFolder={props.instanceFolder}
|
<For each={attachments()}>
|
||||||
sessionId={activeSession.id}
|
{(attachment) => {
|
||||||
onSend={handleSendMessage}
|
const isText = attachment.source.type === "text"
|
||||||
onRunShell={handleRunShell}
|
return (
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||||
isSessionBusy={sessionBusy()}
|
<span class="font-mono">{attachment.display}</span>
|
||||||
onAbortSession={handleAbortSession}
|
<Show when={isText}>
|
||||||
registerQuoteHandler={registerQuoteHandler}
|
<button
|
||||||
/>
|
type="button"
|
||||||
|
class="attachment-expand"
|
||||||
|
onClick={() => handleExpandTextAttachment(attachment)}
|
||||||
|
aria-label="Expand pasted text"
|
||||||
|
title="Insert pasted text"
|
||||||
|
>
|
||||||
|
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attachment-remove"
|
||||||
|
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<PromptInput
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
instanceFolder={props.instanceFolder}
|
||||||
|
sessionId={activeSession.id}
|
||||||
|
onSend={handleSendMessage}
|
||||||
|
onRunShell={handleRunShell}
|
||||||
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
|
isSessionBusy={sessionBusy()}
|
||||||
|
disabled={sessionNeedsInput()}
|
||||||
|
onAbortSession={handleAbortSession}
|
||||||
|
registerQuoteHandler={registerQuoteHandler}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createSignal, Show, For, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||||
@@ -6,8 +6,10 @@ import { useTheme } from "../lib/theme"
|
|||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import type { DiffViewMode } from "../stores/preferences"
|
import type { DiffViewMode } from "../stores/preferences"
|
||||||
import { sendPermissionResponse } from "../stores/instances"
|
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||||
|
import type { PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
|
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
|
||||||
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
import { resolveToolRenderer } from "./tool-call/renderers"
|
||||||
import type {
|
import type {
|
||||||
@@ -31,6 +33,29 @@ type ToolState = import("@opencode-ai/sdk").ToolState
|
|||||||
|
|
||||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||||
|
|
||||||
|
type QuestionOption = { label: string; description: string }
|
||||||
|
|
||||||
|
type QuestionPrompt = {
|
||||||
|
header: string
|
||||||
|
question: string
|
||||||
|
options: QuestionOption[]
|
||||||
|
multiple?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionToolBlockProps = {
|
||||||
|
toolName: Accessor<string>
|
||||||
|
toolState: Accessor<ToolState | undefined>
|
||||||
|
toolCallId: Accessor<string>
|
||||||
|
request: Accessor<QuestionRequest | undefined>
|
||||||
|
active: Accessor<boolean>
|
||||||
|
submitting: Accessor<boolean>
|
||||||
|
error: Accessor<string | null>
|
||||||
|
draftAnswers: Accessor<Record<string, string[][]>>
|
||||||
|
setDraftAnswers: (updater: (prev: Record<string, string[][]>) => Record<string, string[][]>) => void
|
||||||
|
onSubmit: () => void | Promise<void>
|
||||||
|
onDismiss: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
@@ -106,6 +131,291 @@ function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
|||||||
return { label: "INFO", icon: "i", rank: 2 }
|
return { label: "INFO", icon: "i", rank: 2 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||||
|
const requestId = createMemo(() => {
|
||||||
|
const state = props.toolState()
|
||||||
|
const request = props.request()
|
||||||
|
return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const questions = createMemo(() => {
|
||||||
|
const state = props.toolState()
|
||||||
|
const request = props.request()
|
||||||
|
const isQuestionTool = props.toolName() === "question"
|
||||||
|
if (!request && !isQuestionTool) return [] as QuestionPrompt[]
|
||||||
|
|
||||||
|
const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? []
|
||||||
|
const list = Array.isArray(questionsSource) ? questionsSource : []
|
||||||
|
return list as QuestionPrompt[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const isVisible = createMemo(() => {
|
||||||
|
const request = props.request()
|
||||||
|
const isQuestionTool = props.toolName() === "question"
|
||||||
|
return Boolean(request) || isQuestionTool
|
||||||
|
})
|
||||||
|
|
||||||
|
const answers = createMemo(() => {
|
||||||
|
const state = props.toolState()
|
||||||
|
|
||||||
|
const completedAnswers =
|
||||||
|
(state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers)
|
||||||
|
? ((state as any).metadata.answers as string[][])
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (completedAnswers) return completedAnswers
|
||||||
|
|
||||||
|
const request = props.request()
|
||||||
|
const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines)
|
||||||
|
|
||||||
|
if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) {
|
||||||
|
return requestAnswers as string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = props.draftAnswers()[requestId()] ?? []
|
||||||
|
return Array.isArray(draft) ? draft : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||||
|
if (!props.active()) return
|
||||||
|
props.setDraftAnswers((prev) => {
|
||||||
|
const current = prev[requestId()] ?? []
|
||||||
|
const updated = [...current]
|
||||||
|
updated[questionIndex] = next
|
||||||
|
return { ...prev, [requestId()]: updated }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOption = (questionIndex: number, label: string) => {
|
||||||
|
const info = questions()[questionIndex]
|
||||||
|
const multi = info?.multiple === true
|
||||||
|
const existing = answers()[questionIndex] ?? []
|
||||||
|
if (multi) {
|
||||||
|
const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label]
|
||||||
|
updateAnswer(questionIndex, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateAnswer(questionIndex, [label])
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDisabled = () => {
|
||||||
|
if (!props.active()) return true
|
||||||
|
if (props.submitting()) return true
|
||||||
|
return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => {
|
||||||
|
if (!props.active()) return
|
||||||
|
const rawValue = input?.value ?? ""
|
||||||
|
const value = rawValue
|
||||||
|
if (value.trim().length === 0) return
|
||||||
|
|
||||||
|
const info = questions()[questionIndex]
|
||||||
|
const multi = info?.multiple === true
|
||||||
|
if (!multi) {
|
||||||
|
// When switching a radio to custom, clear existing selection first.
|
||||||
|
updateAnswer(questionIndex, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleOption(questionIndex, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
|
||||||
|
if (!props.active()) return
|
||||||
|
if (valuesToRemove.length === 0) return
|
||||||
|
const existing = answers()[questionIndex] ?? []
|
||||||
|
const next = existing.filter((value) => !valuesToRemove.includes(value))
|
||||||
|
updateAnswer(questionIndex, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => {
|
||||||
|
if (!props.active()) return
|
||||||
|
|
||||||
|
const value = input.value
|
||||||
|
const trimmed = value.trim()
|
||||||
|
const info = questions()[questionIndex]
|
||||||
|
const multi = info?.multiple === true
|
||||||
|
|
||||||
|
if (!multi) {
|
||||||
|
updateAnswer(questionIndex, trimmed.length > 0 ? [value] : [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label))
|
||||||
|
const existing = answers()[questionIndex] ?? []
|
||||||
|
const last = input.dataset.lastValue ?? ""
|
||||||
|
|
||||||
|
let next = existing.filter((item) => item !== last)
|
||||||
|
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
// Only treat it as custom if it doesn't match an existing option label.
|
||||||
|
if (!optionLabels.has(trimmed) && !next.includes(value)) {
|
||||||
|
next = [...next, value]
|
||||||
|
} else if (optionLabels.has(trimmed)) {
|
||||||
|
// If they typed an existing option label, don't treat it as custom.
|
||||||
|
} else if (!next.includes(value)) {
|
||||||
|
next = [...next, value]
|
||||||
|
}
|
||||||
|
input.dataset.lastValue = value
|
||||||
|
} else {
|
||||||
|
delete input.dataset.lastValue
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAnswer(questionIndex, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isVisible() && questions().length > 0}>
|
||||||
|
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||||
|
<div class="tool-call-permission-header">
|
||||||
|
<span class="tool-call-permission-label">
|
||||||
|
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
|
||||||
|
</span>
|
||||||
|
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-call-permission-body">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<For each={questions()}>
|
||||||
|
{(q, index) => {
|
||||||
|
const i = () => index()
|
||||||
|
const multi = () => q?.multiple === true
|
||||||
|
const selected = () => answers()[i()] ?? []
|
||||||
|
const inputType = () => (multi() ? "checkbox" : "radio")
|
||||||
|
const groupName = () => `question-${requestId()}-${i()}`
|
||||||
|
const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label))
|
||||||
|
const customSelected = () => selected().filter((value) => !optionLabels().has(value))
|
||||||
|
const customValue = () => customSelected()[0] ?? ""
|
||||||
|
const customChecked = () => customValue().length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<div class="text-xs">
|
||||||
|
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={multi()}>
|
||||||
|
<div class="text-xs text-muted">Multiple</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col gap-1">
|
||||||
|
<For each={q?.options ?? []}>
|
||||||
|
{(opt) => {
|
||||||
|
const checked = () => selected().includes(opt.label)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
class={`flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||||
|
title={opt.description}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={inputType()}
|
||||||
|
name={groupName()}
|
||||||
|
checked={checked()}
|
||||||
|
disabled={!props.active() || props.submitting()}
|
||||||
|
onChange={() => toggleOption(i(), opt.label)}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="text-sm leading-tight">{opt.label}</div>
|
||||||
|
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||||
|
title="Type a custom answer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={inputType()}
|
||||||
|
name={groupName()}
|
||||||
|
checked={customChecked()}
|
||||||
|
disabled={!props.active() || props.submitting()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const container = e.currentTarget.closest("label")
|
||||||
|
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
|
||||||
|
if (!props.active()) return
|
||||||
|
if (customChecked()) {
|
||||||
|
clearCustomAnswer(i(), customSelected())
|
||||||
|
if (input) {
|
||||||
|
delete input.dataset.lastValue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toggleFromCustomInput(i(), input)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
|
<div class="text-sm leading-tight">Custom answer</div>
|
||||||
|
<input
|
||||||
|
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type your own answer"
|
||||||
|
disabled={!props.active() || props.submitting()}
|
||||||
|
value={customValue()}
|
||||||
|
onFocus={(e) => {
|
||||||
|
if (!props.active()) return
|
||||||
|
// Keep the radio/checkbox selected while editing.
|
||||||
|
toggleFromCustomInput(i(), e.currentTarget)
|
||||||
|
}}
|
||||||
|
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={props.active()}>
|
||||||
|
<div class="tool-call-permission-actions">
|
||||||
|
<div class="tool-call-permission-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={submitDisabled()}
|
||||||
|
onClick={() => props.onSubmit()}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={props.submitting()}
|
||||||
|
onClick={() => props.onDismiss()}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-call-permission-shortcuts">
|
||||||
|
<kbd class="kbd">Enter</kbd>
|
||||||
|
<span>Submit</span>
|
||||||
|
<kbd class="kbd">Esc</kbd>
|
||||||
|
<span>Dismiss</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={props.error()}>
|
||||||
|
<div class="tool-call-permission-error">{props.error()}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.active() && props.request()}>
|
||||||
|
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||||
if (!state) return []
|
if (!state) return []
|
||||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||||
@@ -239,6 +549,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
|
const activeRequest = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
|
||||||
|
|
||||||
const cacheVersion = createMemo(() => {
|
const cacheVersion = createMemo(() => {
|
||||||
if (typeof props.partVersion === "number") {
|
if (typeof props.partVersion === "number") {
|
||||||
@@ -278,6 +589,16 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
return toolCallMemo()?.pendingPermission
|
return toolCallMemo()?.pendingPermission
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const questionState = createMemo(() => store().getQuestionState(props.messageId, toolCallIdentifier()))
|
||||||
|
const pendingQuestion = createMemo(() => {
|
||||||
|
const state = questionState()
|
||||||
|
if (state) {
|
||||||
|
return { request: state.entry.request as QuestionRequest, active: state.active }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||||
|
|
||||||
@@ -292,27 +613,45 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
|
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
|
||||||
|
|
||||||
|
const isPermissionActive = createMemo(() => {
|
||||||
|
const pending = pendingPermission()
|
||||||
|
if (!pending?.permission) return false
|
||||||
|
const active = activeRequest()
|
||||||
|
return active?.kind === "permission" && active.id === pending.permission.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const isQuestionActive = createMemo(() => {
|
||||||
|
const pending = pendingQuestion()
|
||||||
|
if (!pending?.request) return false
|
||||||
|
const active = activeRequest()
|
||||||
|
return active?.kind === "question" && active.id === pending.request.id
|
||||||
|
})
|
||||||
|
|
||||||
const expanded = () => {
|
const expanded = () => {
|
||||||
const permission = pendingPermission()
|
if (isPermissionActive() || isQuestionActive()) return true
|
||||||
if (permission?.active) return true
|
|
||||||
const override = userExpanded()
|
const override = userExpanded()
|
||||||
if (override !== null) return override
|
if (override !== null) return override
|
||||||
return defaultExpandedForTool()
|
return defaultExpandedForTool()
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
||||||
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
const questionDetails = createMemo(() => pendingQuestion()?.request)
|
||||||
|
|
||||||
const activePermissionKey = createMemo(() => {
|
const activePermissionKey = createMemo(() => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
return permission && isPermissionActive() ? permission.id : ""
|
return permission && isPermissionActive() ? permission.id : ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeQuestionKey = createMemo(() => {
|
||||||
|
const request = questionDetails()
|
||||||
|
return request && isQuestionActive() ? request.id : ""
|
||||||
|
})
|
||||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||||
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
|
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
|
||||||
|
|
||||||
const diagnosticsExpanded = () => {
|
const diagnosticsExpanded = () => {
|
||||||
const permission = pendingPermission()
|
if (isPermissionActive() || isQuestionActive()) return true
|
||||||
if (permission?.active) return true
|
|
||||||
const override = diagnosticsOverride()
|
const override = diagnosticsOverride()
|
||||||
if (override !== undefined) return override
|
if (override !== undefined) return override
|
||||||
return diagnosticsDefaultExpanded()
|
return diagnosticsDefaultExpanded()
|
||||||
@@ -513,7 +852,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeKey = activePermissionKey()
|
const activeKey = activePermissionKey() || activeQuestionKey()
|
||||||
if (!activeKey) return
|
if (!activeKey) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
|
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||||
@@ -524,15 +863,94 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const activeKey = activePermissionKey()
|
const activeKey = activePermissionKey()
|
||||||
if (!activeKey) return
|
if (!activeKey) return
|
||||||
const handler = (event: KeyboardEvent) => {
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
const permission = permissionDetails()
|
||||||
|
if (!permission || !isPermissionActive()) return
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handlePermissionResponse("once")
|
void handlePermissionResponse(permission, "once")
|
||||||
} else if (event.key === "a" || event.key === "A") {
|
} else if (event.key === "a" || event.key === "A") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handlePermissionResponse("always")
|
void handlePermissionResponse(permission, "always")
|
||||||
} else if (event.key === "d" || event.key === "D") {
|
} else if (event.key === "d" || event.key === "D") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handlePermissionResponse("reject")
|
void handlePermissionResponse(permission, "reject")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handler)
|
||||||
|
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
const [questionSubmitting, setQuestionSubmitting] = createSignal(false)
|
||||||
|
const [questionError, setQuestionError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal<Record<string, string[][]>>({})
|
||||||
|
|
||||||
|
function isTextInputFocused() {
|
||||||
|
const active = document.activeElement
|
||||||
|
return (
|
||||||
|
active?.tagName === "TEXTAREA" ||
|
||||||
|
active?.tagName === "INPUT" ||
|
||||||
|
(active?.hasAttribute("contenteditable") ?? false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQuestionSubmit() {
|
||||||
|
const request = questionDetails()
|
||||||
|
if (!request || !isQuestionActive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const answers = (questionDraftAnswers()[request.id] ?? []).map((x) => (Array.isArray(x) ? x : []))
|
||||||
|
const normalized = request.questions.map((_, index) => {
|
||||||
|
const row = answers[index] ?? []
|
||||||
|
return row.map((value) => value.trim()).filter((value) => value.length > 0)
|
||||||
|
})
|
||||||
|
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
|
||||||
|
setQuestionError("Please answer all questions before submitting.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuestionSubmitting(true)
|
||||||
|
setQuestionError(null)
|
||||||
|
try {
|
||||||
|
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
|
||||||
|
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to send question reply", error)
|
||||||
|
setQuestionError(error instanceof Error ? error.message : "Unable to reply")
|
||||||
|
} finally {
|
||||||
|
setQuestionSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQuestionDismiss() {
|
||||||
|
const request = questionDetails()
|
||||||
|
if (!request || !isQuestionActive()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQuestionSubmitting(true)
|
||||||
|
setQuestionError(null)
|
||||||
|
try {
|
||||||
|
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
|
||||||
|
await sendQuestionReject(props.instanceId, sessionId, request.id)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to reject question", error)
|
||||||
|
setQuestionError(error instanceof Error ? error.message : "Unable to dismiss")
|
||||||
|
} finally {
|
||||||
|
setQuestionSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const activeKey = activeQuestionKey()
|
||||||
|
if (!activeKey) return
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if (isTextInputFocused()) return
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void handleQuestionSubmit()
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
void handleQuestionDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handler)
|
document.addEventListener("keydown", handler)
|
||||||
@@ -563,7 +981,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const combinedStatusClass = () => {
|
const combinedStatusClass = () => {
|
||||||
const base = statusClass()
|
const base = statusClass()
|
||||||
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
|
return pendingPermission() || pendingQuestion() ? `${base} tool-call-awaiting-permission` : base
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
@@ -831,11 +1249,8 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return renderer().renderBody(rendererContext)
|
return renderer().renderBody(rendererContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePermissionResponse(response: "once" | "always" | "reject") {
|
async function handlePermissionResponse(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
|
||||||
const permission = permissionDetails()
|
if (!permission) return
|
||||||
if (!permission || !isPermissionActive()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPermissionSubmitting(true)
|
setPermissionSubmitting(true)
|
||||||
setPermissionError(null)
|
setPermissionError(null)
|
||||||
try {
|
try {
|
||||||
@@ -901,37 +1316,37 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show when={!active}>
|
||||||
when={active}
|
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
|
||||||
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
|
</Show>
|
||||||
>
|
<div class="tool-call-permission-actions">
|
||||||
<div class="tool-call-permission-actions">
|
<div class="tool-call-permission-buttons">
|
||||||
<div class="tool-call-permission-buttons">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="tool-call-permission-button"
|
||||||
class="tool-call-permission-button"
|
disabled={permissionSubmitting()}
|
||||||
disabled={permissionSubmitting()}
|
onClick={() => void handlePermissionResponse(permission, "once")}
|
||||||
onClick={() => handlePermissionResponse("once")}
|
>
|
||||||
>
|
Allow Once
|
||||||
Allow Once
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="tool-call-permission-button"
|
||||||
class="tool-call-permission-button"
|
disabled={permissionSubmitting()}
|
||||||
disabled={permissionSubmitting()}
|
onClick={() => void handlePermissionResponse(permission, "always")}
|
||||||
onClick={() => handlePermissionResponse("always")}
|
>
|
||||||
>
|
Always Allow
|
||||||
Always Allow
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="tool-call-permission-button"
|
||||||
class="tool-call-permission-button"
|
disabled={permissionSubmitting()}
|
||||||
disabled={permissionSubmitting()}
|
onClick={() => void handlePermissionResponse(permission, "reject")}
|
||||||
onClick={() => handlePermissionResponse("reject")}
|
>
|
||||||
>
|
Deny
|
||||||
Deny
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<Show when={active}>
|
||||||
<div class="tool-call-permission-shortcuts">
|
<div class="tool-call-permission-shortcuts">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Allow once</span>
|
<span>Allow once</span>
|
||||||
@@ -940,16 +1355,49 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
<kbd class="kbd">D</kbd>
|
<kbd class="kbd">D</kbd>
|
||||||
<span>Deny</span>
|
<span>Deny</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Show when={permissionError()}>
|
|
||||||
<div class="tool-call-permission-error">{permissionError()}</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={permissionError()}>
|
||||||
|
<div class="tool-call-permission-error">{permissionError()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderQuestionBlock = () => (
|
||||||
|
<QuestionToolBlock
|
||||||
|
toolName={toolName}
|
||||||
|
toolState={toolState}
|
||||||
|
toolCallId={toolCallIdentifier}
|
||||||
|
request={questionDetails}
|
||||||
|
active={isQuestionActive}
|
||||||
|
submitting={questionSubmitting}
|
||||||
|
error={questionError}
|
||||||
|
draftAnswers={questionDraftAnswers}
|
||||||
|
setDraftAnswers={setQuestionDraftAnswers}
|
||||||
|
onSubmit={() => void handleQuestionSubmit()}
|
||||||
|
onDismiss={() => void handleQuestionDismiss()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const request = questionDetails()
|
||||||
|
if (!request) {
|
||||||
|
setQuestionSubmitting(false)
|
||||||
|
setQuestionError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQuestionError(null)
|
||||||
|
const requestId = request.id
|
||||||
|
setQuestionDraftAnswers((prev) => {
|
||||||
|
if (prev[requestId]) return prev
|
||||||
|
const initial = request.questions.map(() => [])
|
||||||
|
return { ...prev, [requestId]: initial }
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
const status = () => toolState()?.status || ""
|
const status = () => toolState()?.status || ""
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -993,6 +1441,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
{renderError()}
|
{renderError()}
|
||||||
|
|
||||||
{renderPermissionBlock()}
|
{renderPermissionBlock()}
|
||||||
|
{renderQuestionBlock()}
|
||||||
|
|
||||||
<Show when={status() === "pending" && !pendingPermission()}>
|
<Show when={status() === "pending" && !pendingPermission()}>
|
||||||
<div class="tool-call-pending-message">
|
<div class="tool-call-pending-message">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { todoRenderer } from "./todo"
|
|||||||
import { webfetchRenderer } from "./webfetch"
|
import { webfetchRenderer } from "./webfetch"
|
||||||
import { writeRenderer } from "./write"
|
import { writeRenderer } from "./write"
|
||||||
import { invalidRenderer } from "./invalid"
|
import { invalidRenderer } from "./invalid"
|
||||||
|
import { questionRenderer } from "./question"
|
||||||
|
|
||||||
const TOOL_RENDERERS: ToolRenderer[] = [
|
const TOOL_RENDERERS: ToolRenderer[] = [
|
||||||
bashRenderer,
|
bashRenderer,
|
||||||
@@ -19,6 +20,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
|
|||||||
webfetchRenderer,
|
webfetchRenderer,
|
||||||
todoRenderer,
|
todoRenderer,
|
||||||
taskRenderer,
|
taskRenderer,
|
||||||
|
questionRenderer,
|
||||||
invalidRenderer,
|
invalidRenderer,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
17
packages/ui/src/components/tool-call/renderers/question.tsx
Normal file
17
packages/ui/src/components/tool-call/renderers/question.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ToolRenderer } from "../types"
|
||||||
|
|
||||||
|
export const questionRenderer: ToolRenderer = {
|
||||||
|
tools: ["question"],
|
||||||
|
getAction: () => "Awaiting answers...",
|
||||||
|
getTitle({ toolState }) {
|
||||||
|
const state = toolState()
|
||||||
|
if (!state) return "Questions"
|
||||||
|
if (state.status === "completed") return "Questions"
|
||||||
|
return "Asking questions"
|
||||||
|
},
|
||||||
|
renderBody() {
|
||||||
|
// The question tool UI is rendered by ToolCall itself so
|
||||||
|
// it can share the same layout for pending/completed.
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ export function getToolIcon(tool: string): string {
|
|||||||
case "todowrite":
|
case "todowrite":
|
||||||
case "todoread":
|
case "todoread":
|
||||||
return "📋"
|
return "📋"
|
||||||
|
case "question":
|
||||||
|
return "❓"
|
||||||
case "list":
|
case "list":
|
||||||
return "📁"
|
return "📁"
|
||||||
case "patch":
|
case "patch":
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||||
scrollToSelected()
|
scrollToSelected()
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const selected = items[selectedIndex()]
|
const selected = items[selectedIndex()]
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -534,7 +534,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
|
|
||||||
<div class="dropdown-footer">
|
<div class="dropdown-footer">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Enter</span> select •{" "}
|
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Tab/Enter</span> select •{" "}
|
||||||
<span class="font-medium">Esc</span> close
|
<span class="font-medium">Esc</span> close
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
38
packages/ui/src/components/version-pill.tsx
Normal file
38
packages/ui/src/components/version-pill.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Show, createEffect, createSignal } from "solid-js"
|
||||||
|
import type { ServerMeta } from "../../../server/src/api-types"
|
||||||
|
import { getServerMeta } from "../lib/server-meta"
|
||||||
|
|
||||||
|
export default function VersionPill() {
|
||||||
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void getServerMeta()
|
||||||
|
.then((result) => setMeta(result))
|
||||||
|
.catch(() => setMeta(null))
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverVersion = () => meta()?.serverVersion
|
||||||
|
const uiVersion = () => meta()?.ui?.version
|
||||||
|
const uiSource = () => meta()?.ui?.source
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={serverVersion() || uiVersion() || uiSource()}>
|
||||||
|
<div class="text-[11px] text-muted whitespace-nowrap">
|
||||||
|
<Show when={serverVersion()}>
|
||||||
|
{(v) => <span>App {v()}</span>}
|
||||||
|
</Show>
|
||||||
|
<Show when={uiVersion() || uiSource()}>
|
||||||
|
<>
|
||||||
|
<Show when={serverVersion()}>
|
||||||
|
<span class="mx-2">·</span>
|
||||||
|
</Show>
|
||||||
|
<span>
|
||||||
|
UI{uiVersion() ? ` ${uiVersion()}` : ""}
|
||||||
|
<Show when={uiSource()}>{(s) => <span class="opacity-70"> ({s()})</span>}</Show>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
logHttp(`${method} ${path}`)
|
logHttp(`${method} ${path}`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { ...init, headers })
|
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await response.text()
|
const message = await response.text()
|
||||||
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||||
@@ -135,6 +135,15 @@ export const serverApi = {
|
|||||||
fetchServerMeta(): Promise<ServerMeta> {
|
fetchServerMeta(): Promise<ServerMeta> {
|
||||||
return request<ServerMeta>("/api/meta")
|
return request<ServerMeta>("/api/meta")
|
||||||
},
|
},
|
||||||
|
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||||
|
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||||
|
},
|
||||||
|
setServerPassword(password: string): Promise<{ ok: boolean; username: string; passwordUserProvided: boolean }> {
|
||||||
|
return request<{ ok: boolean; username: string; passwordUserProvided: boolean }>("/api/auth/password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
})
|
||||||
|
},
|
||||||
deleteWorkspace(id: string): Promise<void> {
|
deleteWorkspace(id: string): Promise<void> {
|
||||||
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
|
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||||
},
|
},
|
||||||
@@ -270,7 +279,7 @@ export const serverApi = {
|
|||||||
},
|
},
|
||||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||||
const source = new EventSource(EVENTS_URL)
|
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ type SSEEvent =
|
|||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
| { type: "permission.updated" | "permission.asked"; properties?: any }
|
| { type: "permission.updated" | "permission.asked"; properties?: any }
|
||||||
| { type: "permission.replied"; properties?: any }
|
| { type: "permission.replied"; properties?: any }
|
||||||
|
| { type: "question.asked"; properties?: any }
|
||||||
|
| { type: "question.replied" | "question.rejected"; properties?: any }
|
||||||
| EventLspUpdated
|
| EventLspUpdated
|
||||||
| TuiToastEvent
|
| TuiToastEvent
|
||||||
| BackgroundProcessUpdatedEvent
|
| BackgroundProcessUpdatedEvent
|
||||||
@@ -144,6 +146,13 @@ class SSEManager {
|
|||||||
case "permission.replied":
|
case "permission.replied":
|
||||||
this.onPermissionReplied?.(instanceId, event as any)
|
this.onPermissionReplied?.(instanceId, event as any)
|
||||||
break
|
break
|
||||||
|
case "question.asked":
|
||||||
|
this.onQuestionAsked?.(instanceId, event as any)
|
||||||
|
break
|
||||||
|
case "question.replied":
|
||||||
|
case "question.rejected":
|
||||||
|
this.onQuestionAnswered?.(instanceId, event as any)
|
||||||
|
break
|
||||||
case "lsp.updated":
|
case "lsp.updated":
|
||||||
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
||||||
break
|
break
|
||||||
@@ -178,6 +187,8 @@ class SSEManager {
|
|||||||
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
|
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
|
||||||
onPermissionUpdated?: (instanceId: string, event: any) => void
|
onPermissionUpdated?: (instanceId: string, event: any) => void
|
||||||
onPermissionReplied?: (instanceId: string, event: any) => void
|
onPermissionReplied?: (instanceId: string, event: any) => void
|
||||||
|
onQuestionAsked?: (instanceId: string, event: any) => void
|
||||||
|
onQuestionAnswered?: (instanceId: string, event: any) => void
|
||||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||||
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
||||||
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Instance, LogEntry } from "../types/instance"
|
|||||||
import type { LspStatus } from "@opencode-ai/sdk/v2"
|
import type { LspStatus } from "@opencode-ai/sdk/v2"
|
||||||
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
|
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
|
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
|
||||||
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
|
import { getQuestionSessionId } from "../types/question"
|
||||||
import { requestData } from "../lib/opencode-api"
|
import { requestData } from "../lib/opencode-api"
|
||||||
import { sdkManager } from "../lib/sdk-manager"
|
import { sdkManager } from "../lib/sdk-manager"
|
||||||
import { sseManager } from "../lib/sse-manager"
|
import { sseManager } from "../lib/sse-manager"
|
||||||
@@ -18,10 +20,10 @@ import {
|
|||||||
} from "./sessions"
|
} from "./sessions"
|
||||||
import { fetchCommands, clearCommands } from "./commands"
|
import { fetchCommands, clearCommands } from "./commands"
|
||||||
import { preferences } from "./preferences"
|
import { preferences } from "./preferences"
|
||||||
import { setSessionPendingPermission } from "./session-state"
|
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||||
import { setHasInstances } from "./ui"
|
import { setHasInstances } from "./ui"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge"
|
import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge"
|
||||||
import { clearCacheForInstance } from "../lib/global-cache"
|
import { clearCacheForInstance } from "../lib/global-cache"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||||
@@ -34,11 +36,30 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
|
|||||||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
// Permission queue management per instance
|
// Interruption queues (permissions + questions) per instance
|
||||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
||||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||||
|
|
||||||
|
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
|
||||||
|
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
|
const questionSessionCounts = new Map<string, Map<string, number>>()
|
||||||
|
const questionEnqueuedAt = new Map<string, number>()
|
||||||
|
|
||||||
|
function ensureQuestionEnqueuedAt(request: QuestionRequest): number {
|
||||||
|
const existing = questionEnqueuedAt.get(request.id)
|
||||||
|
if (existing) return existing
|
||||||
|
const now = Date.now()
|
||||||
|
questionEnqueuedAt.set(request.id, now)
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterruptionKind = "permission" | "question"
|
||||||
|
|
||||||
|
type ActiveInterruption = { kind: InterruptionKind; id: string } | null
|
||||||
|
|
||||||
|
const [activeInterruption, setActiveInterruption] = createSignal<Map<string, ActiveInterruption>>(new Map())
|
||||||
|
|
||||||
function syncHasInstancesFlag() {
|
function syncHasInstancesFlag() {
|
||||||
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||||
setHasInstances(readyExists)
|
setHasInstances(readyExists)
|
||||||
@@ -71,6 +92,19 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureActiveInstanceSelected(): void {
|
||||||
|
const current = activeInstanceId()
|
||||||
|
const instanceMap = instances()
|
||||||
|
if (current && instanceMap.has(current)) return
|
||||||
|
|
||||||
|
for (const [id, instance] of instanceMap.entries()) {
|
||||||
|
if (instance.status === "ready") {
|
||||||
|
setActiveInstanceId(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||||
const mapped = workspaceDescriptorToInstance(descriptor)
|
const mapped = workspaceDescriptorToInstance(descriptor)
|
||||||
if (instances().has(descriptor.id)) {
|
if (instances().has(descriptor.id)) {
|
||||||
@@ -81,6 +115,9 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
|||||||
|
|
||||||
if (descriptor.status === "ready") {
|
if (descriptor.status === "ready") {
|
||||||
attachClient(descriptor)
|
attachClient(descriptor)
|
||||||
|
// If no tab is currently selected (common after UI refresh),
|
||||||
|
// auto-select the first ready instance.
|
||||||
|
ensureActiveInstanceSelected()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +193,38 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncPendingQuestions(instanceId: string): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance?.client) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remote = await requestData<QuestionRequest[]>(
|
||||||
|
instance.client.question.list(),
|
||||||
|
"question.list",
|
||||||
|
)
|
||||||
|
|
||||||
|
const remoteIds = new Set(remote.map((item) => item.id))
|
||||||
|
const local = getQuestionQueue(instanceId)
|
||||||
|
|
||||||
|
// Remove any stale local requests missing from server.
|
||||||
|
for (const entry of local) {
|
||||||
|
if (!remoteIds.has(entry.id)) {
|
||||||
|
removeQuestionFromQueue(instanceId, entry.id)
|
||||||
|
removeQuestionV2(instanceId, entry.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert all server-side pending questions.
|
||||||
|
for (const request of remote) {
|
||||||
|
ensureQuestionEnqueuedAt(request)
|
||||||
|
addQuestionToQueue(instanceId, request)
|
||||||
|
upsertQuestionV2(instanceId, request)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Failed to sync pending questions", { instanceId, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function hydrateInstanceData(instanceId: string) {
|
async function hydrateInstanceData(instanceId: string) {
|
||||||
try {
|
try {
|
||||||
await fetchSessions(instanceId)
|
await fetchSessions(instanceId)
|
||||||
@@ -166,20 +235,24 @@ async function hydrateInstanceData(instanceId: string) {
|
|||||||
if (!instance?.client) return
|
if (!instance?.client) return
|
||||||
await fetchCommands(instanceId, instance.client)
|
await fetchCommands(instanceId, instance.client)
|
||||||
await syncPendingPermissions(instanceId)
|
await syncPendingPermissions(instanceId)
|
||||||
|
await syncPendingQuestions(instanceId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to fetch initial data", error)
|
log.error("Failed to fetch initial data", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async function initializeWorkspaces() {
|
void (async function initializeWorkspaces() {
|
||||||
try {
|
try {
|
||||||
const workspaces = await serverApi.fetchWorkspaces()
|
const workspaces = await serverApi.fetchWorkspaces()
|
||||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||||
|
// After a UI refresh, we may have instances but no active selection.
|
||||||
|
ensureActiveInstanceSelected()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load workspaces", error)
|
log.error("Failed to load workspaces", error)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|
||||||
serverEvents.on("*", (event) => handleWorkspaceEvent(event))
|
serverEvents.on("*", (event) => handleWorkspaceEvent(event))
|
||||||
|
|
||||||
function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||||
@@ -327,6 +400,7 @@ function removeInstance(id: string) {
|
|||||||
removeLogContainer(id)
|
removeLogContainer(id)
|
||||||
clearCommands(id)
|
clearCommands(id)
|
||||||
clearPermissionQueue(id)
|
clearPermissionQueue(id)
|
||||||
|
clearQuestionQueue(id)
|
||||||
clearInstanceMetadata(id)
|
clearInstanceMetadata(id)
|
||||||
|
|
||||||
if (activeInstanceId() === id) {
|
if (activeInstanceId() === id) {
|
||||||
@@ -429,6 +503,79 @@ function getPermissionQueueLength(instanceId: string): number {
|
|||||||
return getPermissionQueue(instanceId).length
|
return getPermissionQueue(instanceId).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getQuestionQueue(instanceId: string): QuestionRequest[] {
|
||||||
|
const queue = questionQueues().get(instanceId)
|
||||||
|
if (!queue) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionQueueLength(instanceId: string): number {
|
||||||
|
return getQuestionQueue(instanceId).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string): number {
|
||||||
|
// Ensure we have a stable timestamp for sorting/ordering.
|
||||||
|
const queue = getQuestionQueue(instanceId)
|
||||||
|
const match = queue.find((q) => q.id === requestId)
|
||||||
|
if (match) {
|
||||||
|
return ensureQuestionEnqueuedAt(match)
|
||||||
|
}
|
||||||
|
return questionEnqueuedAt.get(requestId) ?? Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeActiveInterruption(instanceId: string): ActiveInterruption {
|
||||||
|
const permissions = getPermissionQueue(instanceId)
|
||||||
|
const questions = getQuestionQueue(instanceId)
|
||||||
|
const firstPermission = permissions[0]
|
||||||
|
const firstQuestion = questions[0]
|
||||||
|
if (!firstPermission && !firstQuestion) return null
|
||||||
|
if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id }
|
||||||
|
if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id }
|
||||||
|
|
||||||
|
const permTime = getPermissionCreatedAt(firstPermission)
|
||||||
|
const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER
|
||||||
|
if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id }
|
||||||
|
return { kind: "question", id: firstQuestion!.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveInterruptionForInstance(instanceId: string, nextActive: ActiveInterruption): void {
|
||||||
|
setActiveInterruption((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (!nextActive) {
|
||||||
|
next.set(instanceId, null)
|
||||||
|
} else {
|
||||||
|
next.set(instanceId, nextActive)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setActivePermissionId((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (nextActive?.kind === "permission") {
|
||||||
|
next.set(instanceId, nextActive.id)
|
||||||
|
} else {
|
||||||
|
next.set(instanceId, null)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setActiveQuestionId((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (nextActive?.kind === "question") {
|
||||||
|
next.set(instanceId, nextActive.id)
|
||||||
|
} else {
|
||||||
|
next.set(instanceId, null)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeActiveInterruption(instanceId: string): void {
|
||||||
|
setActiveInterruptionForInstance(instanceId, computeActiveInterruption(instanceId))
|
||||||
|
}
|
||||||
|
|
||||||
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||||
let sessionCounts = permissionSessionCounts.get(instanceId)
|
let sessionCounts = permissionSessionCounts.get(instanceId)
|
||||||
if (!sessionCounts) {
|
if (!sessionCounts) {
|
||||||
@@ -464,6 +611,41 @@ function clearSessionPendingCounts(instanceId: string): void {
|
|||||||
permissionSessionCounts.delete(instanceId)
|
permissionSessionCounts.delete(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function incrementQuestionSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||||
|
let sessionCounts = questionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) {
|
||||||
|
sessionCounts = new Map()
|
||||||
|
questionSessionCounts.set(instanceId, sessionCounts)
|
||||||
|
}
|
||||||
|
const current = sessionCounts.get(sessionId) ?? 0
|
||||||
|
sessionCounts.set(sessionId, current + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementQuestionSessionPendingCount(instanceId: string, sessionId: string): number {
|
||||||
|
const sessionCounts = questionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) return 0
|
||||||
|
const current = sessionCounts.get(sessionId) ?? 0
|
||||||
|
if (current <= 1) {
|
||||||
|
sessionCounts.delete(sessionId)
|
||||||
|
if (sessionCounts.size === 0) {
|
||||||
|
questionSessionCounts.delete(instanceId)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const nextValue = current - 1
|
||||||
|
sessionCounts.set(sessionId, nextValue)
|
||||||
|
return nextValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQuestionSessionPendingCounts(instanceId: string): void {
|
||||||
|
const sessionCounts = questionSessionCounts.get(instanceId)
|
||||||
|
if (!sessionCounts) return
|
||||||
|
for (const sessionId of sessionCounts.keys()) {
|
||||||
|
setSessionPendingQuestion(instanceId, sessionId, false)
|
||||||
|
}
|
||||||
|
questionSessionCounts.delete(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
|
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
|
||||||
let inserted = false
|
let inserted = false
|
||||||
|
|
||||||
@@ -485,13 +667,7 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setActivePermissionId((prev) => {
|
recomputeActiveInterruption(instanceId)
|
||||||
const next = new Map(prev)
|
|
||||||
if (!next.get(instanceId)) {
|
|
||||||
next.set(instanceId, permission.id)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const sessionId = getPermissionSessionId(permission)
|
const sessionId = getPermissionSessionId(permission)
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -526,15 +702,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
|||||||
|
|
||||||
const updatedQueue = getPermissionQueue(instanceId)
|
const updatedQueue = getPermissionQueue(instanceId)
|
||||||
|
|
||||||
setActivePermissionId((prev) => {
|
recomputeActiveInterruption(instanceId)
|
||||||
const next = new Map(prev)
|
|
||||||
const activeId = next.get(instanceId)
|
|
||||||
if (activeId === permissionId) {
|
|
||||||
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as PermissionRequestLike) : null
|
|
||||||
next.set(instanceId, nextPermission?.id ?? null)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const removed = removedPermission
|
const removed = removedPermission
|
||||||
if (removed) {
|
if (removed) {
|
||||||
@@ -558,16 +726,140 @@ function clearPermissionQueue(instanceId: string): void {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
clearSessionPendingCounts(instanceId)
|
clearSessionPendingCounts(instanceId)
|
||||||
|
recomputeActiveInterruption(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addQuestionToQueue(instanceId: string, request: QuestionRequest): void {
|
||||||
|
let inserted = false
|
||||||
|
|
||||||
|
setQuestionQueues((prev) => {
|
||||||
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
|
|
||||||
setActivePermissionId((prev) => {
|
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
next.set(instanceId, permissionId)
|
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
|
||||||
|
|
||||||
|
if (queue.some((q) => q.id === request.id)) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureQuestionEnqueuedAt(request)
|
||||||
|
const updatedQueue = [...queue, request].sort((a, b) => {
|
||||||
|
return ensureQuestionEnqueuedAt(a) - ensureQuestionEnqueuedAt(b)
|
||||||
|
})
|
||||||
|
next.set(instanceId, updatedQueue)
|
||||||
|
inserted = true
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recomputeActiveInterruption(instanceId)
|
||||||
|
|
||||||
|
const sessionId = getQuestionSessionId(request)
|
||||||
|
if (sessionId) {
|
||||||
|
incrementQuestionSessionPendingCount(instanceId, sessionId)
|
||||||
|
setSessionPendingQuestion(instanceId, sessionId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQuestionFromQueue(instanceId: string, requestId: string): void {
|
||||||
|
const removedSessionId = getQuestionSessionId(getQuestionQueue(instanceId).find((q) => q.id === requestId))
|
||||||
|
|
||||||
|
setQuestionQueues((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
|
||||||
|
const filtered = queue.filter((item) => item.id !== requestId)
|
||||||
|
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
next.set(instanceId, filtered)
|
||||||
|
} else {
|
||||||
|
next.delete(instanceId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
questionEnqueuedAt.delete(requestId)
|
||||||
|
recomputeActiveInterruption(instanceId)
|
||||||
|
|
||||||
|
if (removedSessionId) {
|
||||||
|
const remaining = decrementQuestionSessionPendingCount(instanceId, removedSessionId)
|
||||||
|
setSessionPendingQuestion(instanceId, removedSessionId, remaining > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQuestionQueue(instanceId: string): void {
|
||||||
|
for (const request of getQuestionQueue(instanceId)) {
|
||||||
|
questionEnqueuedAt.delete(request.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuestionQueues((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(instanceId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setActiveQuestionId((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(instanceId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
clearQuestionSessionPendingCounts(instanceId)
|
||||||
|
recomputeActiveInterruption(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
|
||||||
|
setActiveInterruptionForInstance(instanceId, { kind: "permission", id: permissionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveQuestionIdForInstance(instanceId: string, requestId: string): void {
|
||||||
|
setActiveInterruptionForInstance(instanceId, { kind: "question", id: requestId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendQuestionReply(
|
||||||
|
instanceId: string,
|
||||||
|
_sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
answers: string[][],
|
||||||
|
): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance?.client) {
|
||||||
|
throw new Error("Instance not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestData(
|
||||||
|
instance.client.question.reply({
|
||||||
|
requestID: requestId,
|
||||||
|
answers,
|
||||||
|
}),
|
||||||
|
"question.reply",
|
||||||
|
)
|
||||||
|
|
||||||
|
removeQuestionFromQueue(instanceId, requestId)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to send question reply", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance?.client) {
|
||||||
|
throw new Error("Instance not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestData(
|
||||||
|
instance.client.question.reject({
|
||||||
|
requestID: requestId,
|
||||||
|
}),
|
||||||
|
"question.reject",
|
||||||
|
)
|
||||||
|
|
||||||
|
removeQuestionFromQueue(instanceId, requestId)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to send question reject", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPermissionResponse(
|
async function sendPermissionResponse(
|
||||||
@@ -655,7 +947,7 @@ export {
|
|||||||
getInstanceLogs,
|
getInstanceLogs,
|
||||||
isInstanceLogStreaming,
|
isInstanceLogStreaming,
|
||||||
setInstanceLogStreaming,
|
setInstanceLogStreaming,
|
||||||
// Permission management
|
// Permission + question management
|
||||||
permissionQueues,
|
permissionQueues,
|
||||||
activePermissionId,
|
activePermissionId,
|
||||||
getPermissionQueue,
|
getPermissionQueue,
|
||||||
@@ -665,6 +957,18 @@ export {
|
|||||||
clearPermissionQueue,
|
clearPermissionQueue,
|
||||||
sendPermissionResponse,
|
sendPermissionResponse,
|
||||||
setActivePermissionIdForInstance,
|
setActivePermissionIdForInstance,
|
||||||
|
questionQueues,
|
||||||
|
activeQuestionId,
|
||||||
|
activeInterruption,
|
||||||
|
getQuestionQueue,
|
||||||
|
getQuestionQueueLength,
|
||||||
|
getQuestionEnqueuedAtForInstance,
|
||||||
|
addQuestionToQueue,
|
||||||
|
removeQuestionFromQueue,
|
||||||
|
clearQuestionQueue,
|
||||||
|
sendQuestionReply,
|
||||||
|
sendQuestionReject,
|
||||||
|
setActiveQuestionIdForInstance,
|
||||||
disconnectedInstance,
|
disconnectedInstance,
|
||||||
acknowledgeDisconnectedInstance,
|
acknowledgeDisconnectedInstance,
|
||||||
fetchLspStatus,
|
fetchLspStatus,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { PermissionRequestLike } from "../../types/permission"
|
import type { PermissionRequestLike } from "../../types/permission"
|
||||||
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
|
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
|
||||||
|
import type { QuestionRequest } from "../../types/question"
|
||||||
|
import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
|
||||||
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import { messageStoreBus } from "./bus"
|
import { messageStoreBus } from "./bus"
|
||||||
@@ -192,6 +194,65 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractQuestionMessageId(request: QuestionRequest): string | undefined {
|
||||||
|
return getQuestionMessageId(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQuestionCallId(request: QuestionRequest): string | undefined {
|
||||||
|
return getQuestionCallId(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertQuestionV2(instanceId: string, request: QuestionRequest): void {
|
||||||
|
if (!request) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
const messageId = extractQuestionMessageId(request)
|
||||||
|
let partId: string | undefined = undefined
|
||||||
|
const callId = extractQuestionCallId(request)
|
||||||
|
if (callId) {
|
||||||
|
partId = resolvePartIdFromCallId(store, messageId, callId)
|
||||||
|
}
|
||||||
|
store.upsertQuestion({
|
||||||
|
request,
|
||||||
|
messageId,
|
||||||
|
partId,
|
||||||
|
enqueuedAt: (request as any).time?.created ?? Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcilePendingQuestionsV2(instanceId: string, sessionId?: string): void {
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
const pending = store.state.questions.queue
|
||||||
|
if (!pending || pending.length === 0) return
|
||||||
|
|
||||||
|
for (const entry of pending) {
|
||||||
|
if (!entry || entry.partId) continue
|
||||||
|
const request = entry.request
|
||||||
|
if (!request) continue
|
||||||
|
|
||||||
|
const questionSessionId = request.sessionID
|
||||||
|
if (sessionId && questionSessionId && questionSessionId !== sessionId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = entry.messageId ?? extractQuestionMessageId(request)
|
||||||
|
const callId = extractQuestionCallId(request)
|
||||||
|
const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId)
|
||||||
|
if (!resolvedPartId) continue
|
||||||
|
|
||||||
|
store.upsertQuestion({
|
||||||
|
...entry,
|
||||||
|
messageId,
|
||||||
|
partId: resolvedPartId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeQuestionV2(instanceId: string, requestId: string): void {
|
||||||
|
if (!requestId) return
|
||||||
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
store.removeQuestion(requestId)
|
||||||
|
}
|
||||||
|
|
||||||
export function removePermissionV2(instanceId: string, permissionId: string): void {
|
export function removePermissionV2(instanceId: string, permissionId: string): void {
|
||||||
if (!permissionId) return
|
if (!permissionId) return
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
PartUpdateInput,
|
PartUpdateInput,
|
||||||
PendingPartEntry,
|
PendingPartEntry,
|
||||||
PermissionEntry,
|
PermissionEntry,
|
||||||
|
QuestionEntry,
|
||||||
ReplaceMessageIdOptions,
|
ReplaceMessageIdOptions,
|
||||||
ScrollSnapshot,
|
ScrollSnapshot,
|
||||||
SessionRecord,
|
SessionRecord,
|
||||||
@@ -40,6 +41,11 @@ function createInitialState(instanceId: string): InstanceMessageState {
|
|||||||
active: null,
|
active: null,
|
||||||
byMessage: {},
|
byMessage: {},
|
||||||
},
|
},
|
||||||
|
questions: {
|
||||||
|
queue: [],
|
||||||
|
active: null,
|
||||||
|
byMessage: {},
|
||||||
|
},
|
||||||
usage: {},
|
usage: {},
|
||||||
scrollState: {},
|
scrollState: {},
|
||||||
latestTodos: {},
|
latestTodos: {},
|
||||||
@@ -193,6 +199,9 @@ export interface InstanceMessageStore {
|
|||||||
upsertPermission: (entry: PermissionEntry) => void
|
upsertPermission: (entry: PermissionEntry) => void
|
||||||
removePermission: (permissionId: string) => void
|
removePermission: (permissionId: string) => void
|
||||||
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
|
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
|
||||||
|
upsertQuestion: (entry: QuestionEntry) => void
|
||||||
|
removeQuestion: (requestId: string) => void
|
||||||
|
getQuestionState: (messageId?: string, partId?: string) => { entry: QuestionEntry; active: boolean } | null
|
||||||
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
|
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
|
||||||
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
|
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
|
||||||
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
|
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
|
||||||
@@ -757,6 +766,18 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const questionMap = state.questions.byMessage[options.oldId]
|
||||||
|
if (questionMap) {
|
||||||
|
setState("questions", "byMessage", options.newId, questionMap)
|
||||||
|
setState("questions", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
const nextByMessage = { ...next.byMessage }
|
||||||
|
delete nextByMessage[options.oldId]
|
||||||
|
next.byMessage = nextByMessage
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const pending = state.pendingParts[options.oldId]
|
const pending = state.pendingParts[options.oldId]
|
||||||
if (pending) {
|
if (pending) {
|
||||||
setState("pendingParts", options.newId, pending)
|
setState("pendingParts", options.newId, pending)
|
||||||
@@ -832,6 +853,60 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return { entry, active }
|
return { entry, active }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertQuestion(entry: QuestionEntry) {
|
||||||
|
const messageKey = entry.messageId ?? "__global__"
|
||||||
|
const partKey = entry.partId ?? entry.request?.id ?? "__global__"
|
||||||
|
|
||||||
|
setState(
|
||||||
|
"questions",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
|
||||||
|
draft.byMessage[messageKey][partKey] = entry
|
||||||
|
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
draft.queue.push(entry)
|
||||||
|
} else {
|
||||||
|
draft.queue[existingIndex] = entry
|
||||||
|
}
|
||||||
|
if (!draft.active || draft.active.request.id === entry.request.id) {
|
||||||
|
draft.active = entry
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQuestion(requestId: string) {
|
||||||
|
setState(
|
||||||
|
"questions",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.queue = draft.queue.filter((item) => item.request.id !== requestId)
|
||||||
|
if (draft.active?.request.id === requestId) {
|
||||||
|
draft.active = draft.queue[0] ?? null
|
||||||
|
}
|
||||||
|
Object.keys(draft.byMessage).forEach((messageKey) => {
|
||||||
|
const partEntries = draft.byMessage[messageKey]
|
||||||
|
Object.keys(partEntries).forEach((partKey) => {
|
||||||
|
if (partEntries[partKey].request.id === requestId) {
|
||||||
|
delete partEntries[partKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (Object.keys(partEntries).length === 0) {
|
||||||
|
delete draft.byMessage[messageKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionState(messageId?: string, partId?: string) {
|
||||||
|
const messageKey = messageId ?? "__global__"
|
||||||
|
const partKey = partId ?? "__global__"
|
||||||
|
const entry = state.questions.byMessage[messageKey]?.[partKey]
|
||||||
|
if (!entry) return null
|
||||||
|
const active = state.questions.active?.request.id === entry.request.id
|
||||||
|
return { entry, active }
|
||||||
|
}
|
||||||
|
|
||||||
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
|
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
|
||||||
const session = state.sessions[sessionId]
|
const session = state.sessions[sessionId]
|
||||||
if (!session) return
|
if (!session) return
|
||||||
@@ -873,6 +948,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setState("questions", "byMessage", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
removedIds.forEach((id) => {
|
||||||
|
if (next[id]) delete next[id]
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
withUsageState(sessionId, (draft) => {
|
withUsageState(sessionId, (draft) => {
|
||||||
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
||||||
})
|
})
|
||||||
@@ -948,6 +1031,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setState("questions", "byMessage", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
messageIds.forEach((id) => {
|
||||||
|
if (next[id]) delete next[id]
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setState("usage", (prev) => {
|
setState("usage", (prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[sessionId]
|
delete next[sessionId]
|
||||||
@@ -1012,9 +1103,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
replaceMessageId,
|
replaceMessageId,
|
||||||
setMessageInfo,
|
setMessageInfo,
|
||||||
getMessageInfo,
|
getMessageInfo,
|
||||||
upsertPermission,
|
upsertPermission,
|
||||||
removePermission,
|
removePermission,
|
||||||
getPermissionState,
|
getPermissionState,
|
||||||
|
upsertQuestion,
|
||||||
|
removeQuestion,
|
||||||
|
getQuestionState,
|
||||||
|
|
||||||
setSessionRevert,
|
setSessionRevert,
|
||||||
getSessionRevert,
|
getSessionRevert,
|
||||||
rebuildUsage,
|
rebuildUsage,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
import type { PermissionRequestLike } from "../../types/permission"
|
import type { PermissionRequestLike } from "../../types/permission"
|
||||||
|
import type { QuestionRequest } from "../../types/question"
|
||||||
|
|
||||||
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
|
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
|
||||||
export type MessageRole = "user" | "assistant"
|
export type MessageRole = "user" | "assistant"
|
||||||
@@ -59,6 +60,19 @@ export interface InstancePermissionState {
|
|||||||
byMessage: Record<string, Record<string, PermissionEntry>>
|
byMessage: Record<string, Record<string, PermissionEntry>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuestionEntry {
|
||||||
|
request: QuestionRequest
|
||||||
|
messageId?: string
|
||||||
|
partId?: string
|
||||||
|
enqueuedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceQuestionState {
|
||||||
|
queue: QuestionEntry[]
|
||||||
|
active: QuestionEntry | null
|
||||||
|
byMessage: Record<string, Record<string, QuestionEntry>>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScrollSnapshot {
|
export interface ScrollSnapshot {
|
||||||
scrollTop: number
|
scrollTop: number
|
||||||
atBottom: boolean
|
atBottom: boolean
|
||||||
@@ -103,6 +117,7 @@ export interface InstanceMessageState {
|
|||||||
pendingParts: Record<string, PendingPartEntry[]>
|
pendingParts: Record<string, PendingPartEntry[]>
|
||||||
sessionRevisions: Record<string, number>
|
sessionRevisions: Record<string, number>
|
||||||
permissions: InstancePermissionState
|
permissions: InstancePermissionState
|
||||||
|
questions: InstanceQuestionState
|
||||||
usage: Record<string, SessionUsageState>
|
usage: Record<string, SessionUsageState>
|
||||||
scrollState: Record<string, ScrollSnapshot>
|
scrollState: Record<string, ScrollSnapshot>
|
||||||
latestTodos: Record<string, LatestTodoSnapshot | undefined>
|
latestTodos: Record<string, LatestTodoSnapshot | undefined>
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
import { createEffect, createSignal } from "solid-js"
|
import { createEffect, createSignal } from "solid-js"
|
||||||
import type { LatestReleaseInfo, WorkspaceEventPayload } from "../../../server/src/api-types"
|
import type { SupportMeta } from "../../../server/src/api-types"
|
||||||
import { getServerMeta } from "../lib/server-meta"
|
import { getServerMeta } from "../lib/server-meta"
|
||||||
import { serverEvents } from "../lib/server-events"
|
|
||||||
import { showToastNotification, ToastHandle } from "../lib/notifications"
|
import { showToastNotification, ToastHandle } from "../lib/notifications"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { hasInstances, showFolderSelection } from "./ui"
|
import { hasInstances, showFolderSelection } from "./ui"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const [availableRelease, setAvailableRelease] = createSignal<LatestReleaseInfo | null>(null)
|
const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null)
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
let visibilityEffectInitialized = false
|
let visibilityEffectInitialized = false
|
||||||
let activeToast: ToastHandle | null = null
|
let activeToast: ToastHandle | null = null
|
||||||
let activeToastVersion: string | null = null
|
let activeToastKey: string | null = null
|
||||||
|
|
||||||
function dismissActiveToast() {
|
function dismissActiveToast() {
|
||||||
if (activeToast) {
|
if (activeToast) {
|
||||||
activeToast.dismiss()
|
activeToast.dismiss()
|
||||||
activeToast = null
|
activeToast = null
|
||||||
activeToastVersion = null
|
activeToastKey = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,28 +29,34 @@ function ensureVisibilityEffect() {
|
|||||||
visibilityEffectInitialized = true
|
visibilityEffectInitialized = true
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const release = availableRelease()
|
const support = supportInfo()
|
||||||
const shouldShow = Boolean(release) && (!hasInstances() || showFolderSelection())
|
const shouldShow = Boolean(support && support.supported === false) && (!hasInstances() || showFolderSelection())
|
||||||
|
|
||||||
if (!shouldShow || !release) {
|
if (!shouldShow || !support || support.supported !== false) {
|
||||||
dismissActiveToast()
|
dismissActiveToast()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeToast || activeToastVersion !== release.version) {
|
const key = `${support.minServerVersion ?? "unknown"}:${support.latestServerVersion ?? "unknown"}`
|
||||||
|
|
||||||
|
if (!activeToast || activeToastKey !== key) {
|
||||||
dismissActiveToast()
|
dismissActiveToast()
|
||||||
activeToast = showToastNotification({
|
activeToast = showToastNotification({
|
||||||
title: `CodeNomad ${release.version}`,
|
title: support.message ?? "Upgrade required",
|
||||||
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
|
message: support.latestServerVersion
|
||||||
|
? `Update to CodeNomad ${support.latestServerVersion} to use the latest UI.`
|
||||||
|
: "Update CodeNomad to use the latest UI.",
|
||||||
variant: "info",
|
variant: "info",
|
||||||
duration: Number.POSITIVE_INFINITY,
|
duration: Number.POSITIVE_INFINITY,
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
action: {
|
action: support.latestServerUrl
|
||||||
label: "View release",
|
? {
|
||||||
href: release.url,
|
label: "Get update",
|
||||||
},
|
href: support.latestServerUrl,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
activeToastVersion = release.version
|
activeToastKey = key
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -64,32 +69,17 @@ export function initReleaseNotifications() {
|
|||||||
|
|
||||||
ensureVisibilityEffect()
|
ensureVisibilityEffect()
|
||||||
void refreshFromMeta()
|
void refreshFromMeta()
|
||||||
|
|
||||||
serverEvents.on("app.releaseAvailable", (event) => {
|
|
||||||
const typedEvent = event as Extract<WorkspaceEventPayload, { type: "app.releaseAvailable" }>
|
|
||||||
applyRelease(typedEvent.release)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshFromMeta() {
|
async function refreshFromMeta() {
|
||||||
try {
|
try {
|
||||||
const meta = await getServerMeta(true)
|
const meta = await getServerMeta(true)
|
||||||
if (meta.latestRelease) {
|
setSupportInfo(meta.support ?? null)
|
||||||
applyRelease(meta.latestRelease)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn("Unable to load server metadata for release info", error)
|
log.warn("Unable to load server metadata for support info", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRelease(release: LatestReleaseInfo | null | undefined) {
|
export function useSupportInfo() {
|
||||||
if (!release) {
|
return supportInfo
|
||||||
setAvailableRelease(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAvailableRelease(release)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAvailableRelease() {
|
|
||||||
return availableRelease
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||||
import { updateSessionInfo } from "./message-v2/session-info"
|
import { updateSessionInfo } from "./message-v2/session-info"
|
||||||
import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge"
|
import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
import { clearCacheForSession } from "../lib/global-cache"
|
import { clearCacheForSession } from "../lib/global-cache"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
@@ -649,7 +649,9 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
// Permissions can be hydrated before messages/tool parts exist in the store.
|
// Permissions can be hydrated before messages/tool parts exist in the store.
|
||||||
// After message hydration, try to attach any pending permissions to tool-call part ids.
|
// After message hydration, try to attach any pending permissions to tool-call part ids.
|
||||||
reconcilePendingPermissionsV2(instanceId, sessionId)
|
reconcilePendingPermissionsV2(instanceId, sessionId)
|
||||||
|
reconcilePendingQuestionsV2(instanceId, sessionId)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load messages:", error)
|
log.error("Failed to load messages:", error)
|
||||||
|
|||||||
@@ -18,8 +18,17 @@ import { getLogger } from "../lib/logger"
|
|||||||
import { requestData } from "../lib/opencode-api"
|
import { requestData } from "../lib/opencode-api"
|
||||||
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
|
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
|
||||||
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
|
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
|
||||||
|
import { getQuestionId, getRequestIdFromQuestionReply } from "../types/question"
|
||||||
|
import type { QuestionRequest } from "../types/question"
|
||||||
|
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
|
||||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
import {
|
||||||
|
instances,
|
||||||
|
addPermissionToQueue,
|
||||||
|
removePermissionFromQueue,
|
||||||
|
addQuestionToQueue,
|
||||||
|
removeQuestionFromQueue,
|
||||||
|
} from "./instances"
|
||||||
import { showAlertDialog } from "./alerts"
|
import { showAlertDialog } from "./alerts"
|
||||||
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
||||||
import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
|
import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
|
||||||
@@ -30,11 +39,14 @@ import { loadMessages } from "./session-api"
|
|||||||
import {
|
import {
|
||||||
applyPartUpdateV2,
|
applyPartUpdateV2,
|
||||||
replaceMessageIdV2,
|
replaceMessageIdV2,
|
||||||
|
reconcilePendingQuestionsV2,
|
||||||
upsertMessageInfoV2,
|
upsertMessageInfoV2,
|
||||||
upsertPermissionV2,
|
upsertPermissionV2,
|
||||||
|
upsertQuestionV2,
|
||||||
removeMessagePartV2,
|
removeMessagePartV2,
|
||||||
removeMessageV2,
|
removeMessageV2,
|
||||||
removePermissionV2,
|
removePermissionV2,
|
||||||
|
removeQuestionV2,
|
||||||
setSessionRevertV2,
|
setSessionRevertV2,
|
||||||
} from "./message-v2/bridge"
|
} from "./message-v2/bridge"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
@@ -102,6 +114,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<
|
|||||||
model: existing?.model ?? fetched.model,
|
model: existing?.model ?? fetched.model,
|
||||||
status: existing?.status === "compacting" ? "compacting" : fetched.status,
|
status: existing?.status === "compacting" ? "compacting" : fetched.status,
|
||||||
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
|
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
|
||||||
|
pendingQuestion: existing?.pendingQuestion ?? false,
|
||||||
}
|
}
|
||||||
instanceSessions.set(sessionId, merged)
|
instanceSessions.set(sessionId, merged)
|
||||||
next.set(instanceId, instanceSessions)
|
next.set(instanceId, instanceSessions)
|
||||||
@@ -218,6 +231,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
|
|
||||||
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
|
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
|
||||||
|
|
||||||
|
if (part.type === "tool" && part.tool === "question") {
|
||||||
|
// Questions can arrive before their tool part exists; re-link now.
|
||||||
|
reconcilePendingQuestionsV2(instanceId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
updateSessionInfo(instanceId, sessionId)
|
updateSessionInfo(instanceId, sessionId)
|
||||||
} else if (event.type === "message.updated") {
|
} else if (event.type === "message.updated") {
|
||||||
@@ -228,8 +245,20 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
const messageId = typeof info.id === "string" ? info.id : undefined
|
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||||
if (!sessionId || !messageId) return
|
if (!sessionId || !messageId) return
|
||||||
|
|
||||||
|
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
||||||
|
const nextUpdated =
|
||||||
|
typeof timeInfo.completed === "number" && timeInfo.completed > 0
|
||||||
|
? timeInfo.completed
|
||||||
|
: typeof timeInfo.updated === "number" && timeInfo.updated > 0
|
||||||
|
? timeInfo.updated
|
||||||
|
: typeof timeInfo.created === "number" && timeInfo.created > 0
|
||||||
|
? timeInfo.created
|
||||||
|
: Date.now()
|
||||||
|
|
||||||
withSession(instanceId, sessionId, (session) => {
|
withSession(instanceId, sessionId, (session) => {
|
||||||
session.time = { ...(session.time ?? {}), updated: Date.now() }
|
const currentUpdated = session.time?.updated ?? 0
|
||||||
|
if (nextUpdated <= currentUpdated) return false
|
||||||
|
session.time = { ...(session.time ?? {}), updated: nextUpdated }
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
@@ -469,12 +498,36 @@ function handlePermissionReplied(instanceId: string, event: { type: string; prop
|
|||||||
removePermissionV2(instanceId, requestId)
|
removePermissionV2(instanceId, requestId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleQuestionAsked(instanceId: string, event: { type: string; properties?: QuestionRequest } | any): void {
|
||||||
|
const request = event?.properties as QuestionRequest | undefined
|
||||||
|
if (!request) return
|
||||||
|
|
||||||
|
log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
|
||||||
|
addQuestionToQueue(instanceId, request)
|
||||||
|
upsertQuestionV2(instanceId, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuestionAnswered(
|
||||||
|
instanceId: string,
|
||||||
|
event: { type: string; properties?: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] } | any,
|
||||||
|
): void {
|
||||||
|
const properties = event?.properties as EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | undefined
|
||||||
|
const requestId = getRequestIdFromQuestionReply(properties)
|
||||||
|
if (!requestId) return
|
||||||
|
|
||||||
|
log.info(`[SSE] Question answered: ${requestId}`)
|
||||||
|
removeQuestionFromQueue(instanceId, requestId)
|
||||||
|
removeQuestionV2(instanceId, requestId)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
handleMessagePartRemoved,
|
handleMessagePartRemoved,
|
||||||
handleMessageRemoved,
|
handleMessageRemoved,
|
||||||
handleMessageUpdate,
|
handleMessageUpdate,
|
||||||
handlePermissionReplied,
|
handlePermissionReplied,
|
||||||
handlePermissionUpdated,
|
handlePermissionUpdated,
|
||||||
|
handleQuestionAsked,
|
||||||
|
handleQuestionAnswered,
|
||||||
handleSessionCompacted,
|
handleSessionCompacted,
|
||||||
handleSessionError,
|
handleSessionError,
|
||||||
handleSessionIdle,
|
handleSessionIdle,
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ type InstanceIndicatorCounts = {
|
|||||||
|
|
||||||
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
|
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
|
||||||
|
|
||||||
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission">): InstanceSessionIndicatorStatus | "idle" {
|
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion">): InstanceSessionIndicatorStatus | "idle" {
|
||||||
if (session.pendingPermission) {
|
if (session.pendingPermission || session.pendingQuestion) {
|
||||||
return "permission"
|
return "permission"
|
||||||
}
|
}
|
||||||
const status = session.status ?? "idle"
|
const status = session.status ?? "idle"
|
||||||
@@ -126,7 +126,7 @@ function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map<stri
|
|||||||
let compacting = 0
|
let compacting = 0
|
||||||
|
|
||||||
for (const session of instanceSessions.values()) {
|
for (const session of instanceSessions.values()) {
|
||||||
if (session.pendingPermission) {
|
if (session.pendingPermission || session.pendingQuestion) {
|
||||||
permission += 1
|
permission += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -305,6 +305,13 @@ function setSessionPendingPermission(instanceId: string, sessionId: string, pend
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSessionPendingQuestion(instanceId: string, sessionId: string, pending: boolean): void {
|
||||||
|
withSession(instanceId, sessionId, (session) => {
|
||||||
|
if (session.pendingQuestion === pending) return false
|
||||||
|
session.pendingQuestion = pending
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function setActiveSession(instanceId: string, sessionId: string): void {
|
function setActiveSession(instanceId: string, sessionId: string): void {
|
||||||
setActiveSessionId((prev) => {
|
setActiveSessionId((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
@@ -383,9 +390,35 @@ function getSessionFamily(instanceId: string, parentId: string): Session[] {
|
|||||||
return [parent, ...children]
|
return [parent, ...children]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionThreadCacheEntry = {
|
||||||
|
signature: string
|
||||||
|
thread: SessionThread
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionThreadCache = {
|
||||||
|
byParentId: Map<string, SessionThreadCacheEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionThreadCache = new Map<string, SessionThreadCache>()
|
||||||
|
|
||||||
|
function getOrCreateSessionThreadCache(instanceId: string): SessionThreadCache {
|
||||||
|
let cache = sessionThreadCache.get(instanceId)
|
||||||
|
if (!cache) {
|
||||||
|
cache = { byParentId: new Map() }
|
||||||
|
sessionThreadCache.set(instanceId, cache)
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionThreads(instanceId: string): SessionThread[] {
|
function getSessionThreads(instanceId: string): SessionThread[] {
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
if (!instanceSessions || instanceSessions.size === 0) return []
|
if (!instanceSessions || instanceSessions.size === 0) {
|
||||||
|
sessionThreadCache.delete(instanceId)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = getOrCreateSessionThreadCache(instanceId)
|
||||||
|
const seenParents = new Set<string>()
|
||||||
|
|
||||||
const parents: Session[] = []
|
const parents: Session[] = []
|
||||||
const childrenByParent = new Map<string, Session[]>()
|
const childrenByParent = new Map<string, Session[]>()
|
||||||
@@ -409,6 +442,8 @@ function getSessionThreads(instanceId: string): SessionThread[] {
|
|||||||
const threads: SessionThread[] = []
|
const threads: SessionThread[] = []
|
||||||
|
|
||||||
for (const parent of parents) {
|
for (const parent of parents) {
|
||||||
|
seenParents.add(parent.id)
|
||||||
|
|
||||||
const children = childrenByParent.get(parent.id) ?? []
|
const children = childrenByParent.get(parent.id) ?? []
|
||||||
if (children.length > 1) {
|
if (children.length > 1) {
|
||||||
children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
|
children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
|
||||||
@@ -418,7 +453,23 @@ function getSessionThreads(instanceId: string): SessionThread[] {
|
|||||||
const latestChild = children[0]?.time.updated ?? 0
|
const latestChild = children[0]?.time.updated ?? 0
|
||||||
const latestUpdated = Math.max(parentUpdated, latestChild)
|
const latestUpdated = Math.max(parentUpdated, latestChild)
|
||||||
|
|
||||||
threads.push({ parent, children, latestUpdated })
|
const childIds = children.map((child) => child.id).join(",")
|
||||||
|
const signature = `${parentUpdated}:${latestChild}:${childIds}`
|
||||||
|
|
||||||
|
const cached = cache.byParentId.get(parent.id)
|
||||||
|
if (cached && cached.signature === signature) {
|
||||||
|
threads.push(cached.thread)
|
||||||
|
} else {
|
||||||
|
const thread: SessionThread = { parent, children, latestUpdated }
|
||||||
|
cache.byParentId.set(parent.id, { signature, thread })
|
||||||
|
threads.push(thread)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const parentId of Array.from(cache.byParentId.keys())) {
|
||||||
|
if (!seenParents.has(parentId)) {
|
||||||
|
cache.byParentId.delete(parentId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
threads.sort((a, b) => {
|
threads.sort((a, b) => {
|
||||||
@@ -660,6 +711,7 @@ export {
|
|||||||
pruneDraftPrompts,
|
pruneDraftPrompts,
|
||||||
withSession,
|
withSession,
|
||||||
setSessionPendingPermission,
|
setSessionPendingPermission,
|
||||||
|
setSessionPendingQuestion,
|
||||||
setSessionStatus,
|
setSessionStatus,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ import {
|
|||||||
handleMessageUpdate,
|
handleMessageUpdate,
|
||||||
handlePermissionReplied,
|
handlePermissionReplied,
|
||||||
handlePermissionUpdated,
|
handlePermissionUpdated,
|
||||||
|
handleQuestionAnswered,
|
||||||
|
handleQuestionAsked,
|
||||||
handleSessionCompacted,
|
handleSessionCompacted,
|
||||||
handleSessionError,
|
handleSessionError,
|
||||||
handleSessionIdle,
|
handleSessionIdle,
|
||||||
@@ -81,6 +83,8 @@ sseManager.onSessionStatus = handleSessionStatus
|
|||||||
sseManager.onTuiToast = handleTuiToast
|
sseManager.onTuiToast = handleTuiToast
|
||||||
sseManager.onPermissionUpdated = handlePermissionUpdated
|
sseManager.onPermissionUpdated = handlePermissionUpdated
|
||||||
sseManager.onPermissionReplied = handlePermissionReplied
|
sseManager.onPermissionReplied = handlePermissionReplied
|
||||||
|
sseManager.onQuestionAsked = handleQuestionAsked
|
||||||
|
sseManager.onQuestionAnswered = handleQuestionAnswered
|
||||||
|
|
||||||
export {
|
export {
|
||||||
abortSession,
|
abortSession,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* Prompt input & attachment styles */
|
|
||||||
.prompt-input-container {
|
.prompt-input-container {
|
||||||
@apply flex flex-col border-t;
|
@apply flex flex-col border-t;
|
||||||
border-color: var(--border-base);
|
border-color: var(--border-base);
|
||||||
@@ -13,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-actions {
|
.prompt-input-actions {
|
||||||
@apply flex flex-col items-center justify-between;
|
@apply flex flex-col items-center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0.5rem 0.25rem;
|
padding: 0.5rem 0.25rem;
|
||||||
@@ -30,25 +29,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-field {
|
.prompt-input-field {
|
||||||
position: absolute;
|
position: relative;
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input {
|
.prompt-input {
|
||||||
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-color: var(--border-base);
|
border-color: var(--border-base);
|
||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-input-field-container.is-expanded {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input-field.is-expanded {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input.is-expanded {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-input-overlay {
|
.prompt-input-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -69,37 +80,42 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-history-top,
|
/* Navigation buttons container (expand, prev, next) */
|
||||||
.prompt-history-bottom {
|
.prompt-nav-buttons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.35rem;
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
bottom: 0.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
|
gap: 0.125rem;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-history-top {
|
.prompt-expand-button,
|
||||||
top: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-history-bottom {
|
|
||||||
bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-history-button {
|
.prompt-history-button {
|
||||||
@apply w-9 h-9 flex items-center justify-center rounded-md;
|
@apply w-7 h-7 flex items-center justify-center rounded-md;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background-color: rgba(15, 23, 42, 0.04);
|
background-color: rgba(15, 23, 42, 0.04);
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-expand-button:hover:not(:disabled),
|
||||||
.prompt-history-button:hover:not(:disabled) {
|
.prompt-history-button:hover:not(:disabled) {
|
||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-expand-button:active:not(:disabled) {
|
||||||
|
background-color: var(--accent-primary);
|
||||||
|
color: var(--text-inverted);
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-expand-button:disabled,
|
||||||
.prompt-history-button:disabled {
|
.prompt-history-button:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -176,6 +192,7 @@
|
|||||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
color: var(--text-inverted);
|
color: var(--text-inverted);
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-button.shell-mode {
|
.send-button.shell-mode {
|
||||||
@@ -211,7 +228,6 @@
|
|||||||
height: 1rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
@apply text-xs;
|
@apply text-xs;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
34
packages/ui/src/types/question.ts
Normal file
34
packages/ui/src/types/question.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type {
|
||||||
|
QuestionRequest,
|
||||||
|
EventQuestionReplied,
|
||||||
|
EventQuestionRejected,
|
||||||
|
} from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
export type { QuestionRequest }
|
||||||
|
|
||||||
|
export function getQuestionId(question: QuestionRequest | null | undefined): string {
|
||||||
|
return question?.id ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionSessionId(question: QuestionRequest | null | undefined): string | undefined {
|
||||||
|
return question?.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionMessageId(question: QuestionRequest | null | undefined): string | undefined {
|
||||||
|
return question?.tool?.messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionCallId(question: QuestionRequest | null | undefined): string | undefined {
|
||||||
|
return question?.tool?.callID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuestionCreatedAt(question: QuestionRequest | null | undefined): number {
|
||||||
|
// v2 schema doesn't include created time; best effort for ordering.
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestIdFromQuestionReply(
|
||||||
|
properties: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
return properties?.requestID
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export interface Session
|
|||||||
}
|
}
|
||||||
version: string // Include version from SDK Session
|
version: string // Include version from SDK Session
|
||||||
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
||||||
|
pendingQuestion?: boolean // Indicates if session is waiting on user input
|
||||||
status: SessionStatus // Single source of truth for session status
|
status: SessionStatus // Single source of truth for session status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
|
import fs from "fs"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
import solid from "vite-plugin-solid"
|
import solid from "vite-plugin-solid"
|
||||||
import { resolve } from "path"
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
const uiPackageJson = JSON.parse(fs.readFileSync(resolve(__dirname, "package.json"), "utf-8")) as { version?: string }
|
||||||
|
const uiVersion = uiPackageJson.version ?? "0.0.0"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: "./src/renderer",
|
root: "./src/renderer",
|
||||||
plugins: [solid()],
|
plugins: [
|
||||||
|
solid(),
|
||||||
|
{
|
||||||
|
name: "emit-ui-version",
|
||||||
|
generateBundle() {
|
||||||
|
this.emitFile({
|
||||||
|
type: "asset",
|
||||||
|
fileName: "ui-version.json",
|
||||||
|
source: JSON.stringify({ uiVersion }, null, 2),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
css: {
|
css: {
|
||||||
postcss: "./postcss.config.js",
|
postcss: "./postcss.config.js",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user