@lazyingart/agintiflow 0.20.122 → 0.20.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,11 +103,13 @@ Provider signup and key pages:
103
103
  | Qwen / DashScope | [https://bailian.console.aliyun.com/](https://bailian.console.aliyun.com/) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` |
104
104
  | GRS AI image tools | [https://grsai.ai/dashboard/api-keys](https://grsai.ai/dashboard/api-keys) | Configure with `/auxiliary grsai` or `aginti login grsai` |
105
105
 
106
- Launch the web UI from the same project:
106
+ The CLI auto-starts or reuses the local web UI from the same project. It tries `http://127.0.0.1:3210` first, then `3211`, `3212`, and so on if the port is already occupied. The active URL is shown in the CLI launch header.
107
+
108
+ Launch the web UI explicitly when you want a foreground web server:
107
109
 
108
110
  ```bash
109
111
  aginti web --port 3210
110
- # open http://127.0.0.1:3210
112
+ # opens http://127.0.0.1:3210, or the next available port
111
113
  ```
112
114
 
113
115
  Run without live model credentials for smoke tests:
@@ -129,7 +131,7 @@ aginti --language de
129
131
  | Goal | Command |
130
132
  | --- | --- |
131
133
  | Start interactive chat | `aginti` or `aginti chat` |
132
- | Start local web app | `aginti web --port 3210` |
134
+ | Start local web app | Auto-starts with `aginti`; foreground mode is `aginti web --port 3210` |
133
135
  | Save provider keys | `aginti auth`, `/auth`, `/login` |
134
136
  | Review current repo | `/review [focus]` |
135
137
  | Toggle SCS quality gate | `/scs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.122",
3
+ "version": "0.20.123",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -68,7 +68,9 @@
68
68
  "scripts/smoke-skillmesh.js",
69
69
  "scripts/smoke-tmux-tools.js",
70
70
  "scripts/smoke-toolchain-docker.js",
71
+ "scripts/smoke-web-autostart.js",
71
72
  "scripts/smoke-web-api.js",
73
+ "scripts/smoke-web-port-fallback.js",
72
74
  "src/",
73
75
  "run.js",
74
76
  "skills/",
@@ -100,11 +102,13 @@
100
102
  "smoke:permission-modes": "node scripts/smoke-permission-modes.js",
101
103
  "smoke:tmux-tools": "node scripts/smoke-tmux-tools.js",
102
104
  "smoke:web-api": "node scripts/smoke-web-api.js",
105
+ "smoke:web-autostart": "node scripts/smoke-web-autostart.js",
106
+ "smoke:web-port-fallback": "node scripts/smoke-web-port-fallback.js",
103
107
  "smoke:autoupdate": "node scripts/smoke-auto-update.js",
104
108
  "real:deepseek": "node scripts/real-deepseek-capabilities.js",
105
109
  "supervision:seed": "node scripts/seed-supervised-homework.js",
106
110
  "storage:migrate": "node bin/aginti-cli.js storage migrate",
107
- "test": "npm run check && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
111
+ "test": "npm run check && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-autostart && npm run smoke:web-port-fallback && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
108
112
  "pack:dry-run": "npm pack --dry-run",
109
113
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
110
114
  },
@@ -14,7 +14,7 @@ AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-a
14
14
 
15
15
  Project-oriented programming for agentic workflows.
16
16
 
17
- AAPS is a prompt-native programming language and visual studio for turning prompts into structured, verifiable pipelines. It connects wet and dry experiments, hardware and software, and human intent with executable agent work through tasks, typed inputs, declared outputs, validation gates, recovery steps, and durable artifacts.
17
+ AAPS is a project oriented and prompt-native programming language and visual studio for turning prompts into structured, verifiable pipelines. It connects wet and dry experiments, hardware and software, and human intent with executable agent work through tasks, typed inputs, declared outputs, validation gates, recovery steps, and durable artifacts.
18
18
 
19
19
  ## Shorter Version
20
20
 
@@ -317,6 +317,15 @@ try {
317
317
  if (launchHeader.includes("launchTagline")) {
318
318
  throw new Error("empty launch tagline should not render the i18n key name");
319
319
  }
320
+ const launchHeaderWithWeb = buildLaunchHeaderLines({
321
+ width: 120,
322
+ packageVersion: "0.0.0",
323
+ animated: false,
324
+ webAppUrl: "http://127.0.0.1:3210",
325
+ }).join("\n");
326
+ if (!launchHeaderWithWeb.includes("webapp: http://127.0.0.1:3210")) {
327
+ throw new Error("launch header did not render the active webapp URL in the tagline row");
328
+ }
320
329
  if (
321
330
  formatElapsedDuration(0) !== "00:00" ||
322
331
  formatElapsedDuration(65_000) !== "01:05" ||
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { ensureAgintiWebApp } from "../src/web-autostart.js";
7
+
8
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
+ const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-web-autostart-"));
10
+ const preferredPort = 44500 + Math.floor(Math.random() * 1000);
11
+ let childPid = 0;
12
+
13
+ try {
14
+ const first = await ensureAgintiWebApp({
15
+ packageDir: repoRoot,
16
+ cwd: runtimeDir,
17
+ preferredPort,
18
+ host: "127.0.0.1",
19
+ });
20
+ if (!first.ok || !first.started || !first.url.includes(`:${preferredPort}`)) {
21
+ throw new Error(`expected auto-start on preferred port, got ${JSON.stringify(first)}`);
22
+ }
23
+ childPid = Number(first.pid) || 0;
24
+ const health = await fetch(`${first.url}/health`).then((response) => response.json());
25
+ if (!health.ok || Number(health.port) !== preferredPort) {
26
+ throw new Error(`auto-started web health was invalid: ${JSON.stringify(health)}`);
27
+ }
28
+ const second = await ensureAgintiWebApp({
29
+ packageDir: repoRoot,
30
+ cwd: runtimeDir,
31
+ preferredPort,
32
+ host: "127.0.0.1",
33
+ });
34
+ if (!second.ok || !second.reused || second.url !== first.url) {
35
+ throw new Error(`expected second auto-start call to reuse existing webapp, got ${JSON.stringify(second)}`);
36
+ }
37
+ console.log(`web auto-start smoke passed: ${first.url}`);
38
+ } finally {
39
+ if (childPid) {
40
+ try {
41
+ process.kill(childPid, "SIGTERM");
42
+ } catch {
43
+ // The child may have already exited during a failed smoke test.
44
+ }
45
+ }
46
+ await fs.rm(runtimeDir, { recursive: true, force: true });
47
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import net from "node:net";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-web-port-fallback-"));
11
+ const host = "127.0.0.1";
12
+ const occupiedPort = 43100 + Math.floor(Math.random() * 1200);
13
+
14
+ function listenOccupier(port) {
15
+ return new Promise((resolve, reject) => {
16
+ const server = net.createServer();
17
+ server.once("error", reject);
18
+ server.listen(port, host, () => resolve(server));
19
+ });
20
+ }
21
+
22
+ function delay(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
25
+
26
+ async function waitForPrintedPort(child, stdout, stderr) {
27
+ const deadline = Date.now() + 12000;
28
+ while (Date.now() < deadline) {
29
+ if (child.exitCode !== null) break;
30
+ const match = stdout().match(/http:\/\/127\.0\.0\.1:(\d+)/);
31
+ if (match) return Number(match[1]);
32
+ await delay(100);
33
+ }
34
+ throw new Error(`web fallback URL was not printed. stdout=${stdout()} stderr=${stderr()}`);
35
+ }
36
+
37
+ async function waitForHealth(port, child, stdout, stderr) {
38
+ const deadline = Date.now() + 12000;
39
+ while (Date.now() < deadline) {
40
+ if (child.exitCode !== null) break;
41
+ try {
42
+ const response = await fetch(`http://${host}:${port}/health`);
43
+ const body = await response.json();
44
+ if (response.ok && body.ok && Number(body.port) === port) return body;
45
+ } catch {
46
+ await delay(200);
47
+ }
48
+ }
49
+ throw new Error(`web fallback health failed. stdout=${stdout()} stderr=${stderr()}`);
50
+ }
51
+
52
+ const occupier = await listenOccupier(occupiedPort);
53
+ let stdout = "";
54
+ let stderr = "";
55
+ const child = spawn(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), "web", "--port", String(occupiedPort), "--host", host], {
56
+ cwd: runtimeDir,
57
+ env: {
58
+ ...process.env,
59
+ AGINTIFLOW_RUNTIME_DIR: runtimeDir,
60
+ AGINTIFLOW_HOME: path.join(runtimeDir, ".agintiflow-home"),
61
+ },
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ });
64
+ child.stdout.on("data", (chunk) => {
65
+ stdout += chunk.toString();
66
+ });
67
+ child.stderr.on("data", (chunk) => {
68
+ stderr += chunk.toString();
69
+ });
70
+
71
+ try {
72
+ const actualPort = await waitForPrintedPort(child, () => stdout.slice(-500), () => stderr.slice(-500));
73
+ if (actualPort <= occupiedPort) {
74
+ throw new Error(`web command did not move past occupied port ${occupiedPort}; got ${actualPort}`);
75
+ }
76
+ await waitForHealth(actualPort, child, () => stdout.slice(-500), () => stderr.slice(-500));
77
+ console.log(`web port fallback smoke passed: ${occupiedPort} -> ${actualPort}`);
78
+ } finally {
79
+ child.kill("SIGTERM");
80
+ occupier.close();
81
+ await fs.rm(runtimeDir, { recursive: true, force: true });
82
+ }
package/src/cli.js CHANGED
@@ -37,6 +37,7 @@ import { handleSkillMeshCommand } from "./skillmesh.js";
37
37
  import { handleAapsCliCommand } from "./aaps-adapter.js";
38
38
  import { formatInstructionTemplateList, normalizeInstructionTemplate } from "./behavior-contract.js";
39
39
  import { applyPermissionMode, normalizePermissionMode } from "./permission-modes.js";
40
+ import { ensureAgintiWebApp } from "./web-autostart.js";
40
41
  import fs from "node:fs/promises";
41
42
  import path from "node:path";
42
43
  import { fileURLToPath } from "node:url";
@@ -91,6 +92,21 @@ function printUnknownCliOptions(options = []) {
91
92
  console.error("Use `--` before a prompt that intentionally starts with a dash.");
92
93
  }
93
94
 
95
+ async function maybeEnsureDefaultWebApp(args = {}, { commandCwd = process.cwd() } = {}) {
96
+ if (args.web) return { ok: false, url: "" };
97
+ try {
98
+ return await ensureAgintiWebApp({
99
+ packageDir,
100
+ cwd: args.commandCwd || commandCwd || process.cwd(),
101
+ host: args.host || process.env.AGINTI_WEB_HOST || "127.0.0.1",
102
+ preferredPort: args.port || process.env.AGINTI_WEB_PORT || 3210,
103
+ language: args.language ? resolveLanguage(args.language) : "",
104
+ });
105
+ } catch (error) {
106
+ return { ok: false, url: "", error: error instanceof Error ? error.message : String(error) };
107
+ }
108
+ }
109
+
94
110
  const CLI_VALUE_OPTIONS = new Set([
95
111
  "--port",
96
112
  "--host",
@@ -1734,9 +1750,11 @@ export async function main(argv = process.argv.slice(2)) {
1734
1750
  if (!prompt) {
1735
1751
  const parsedResumeOptions = parseArgs(resumeOptions.optionArgv);
1736
1752
  exitOnUnknownOptions(parsedResumeOptions);
1753
+ const webLaunch = await maybeEnsureDefaultWebApp(parsedResumeOptions, { commandCwd });
1737
1754
  await startInteractiveCli(agentDefaults({ ...parsedResumeOptions, resume: sessionId, commandCwd: parsedResumeOptions.commandCwd || commandCwd }), {
1738
1755
  packageDir,
1739
1756
  packageVersion: packageJson.version,
1757
+ webAppUrl: webLaunch.ok ? webLaunch.url : "",
1740
1758
  });
1741
1759
  return;
1742
1760
  }
@@ -1744,6 +1762,7 @@ export async function main(argv = process.argv.slice(2)) {
1744
1762
  exitOnUnknownOptions(parsedResumeArgs);
1745
1763
  const resumeArgs = agentDefaults({ ...parsedResumeArgs, resume: sessionId, goal: prompt, commandCwd: parsedResumeArgs.commandCwd || commandCwd });
1746
1764
  if (!(await ensureDeepSeekKeyForOneShot(resumeArgs))) process.exit(1);
1765
+ await maybeEnsureDefaultWebApp(resumeArgs, { commandCwd });
1747
1766
  const config = loadConfig(resumeArgs, { packageDir });
1748
1767
  await runAgent(config);
1749
1768
  return;
@@ -1763,7 +1782,8 @@ export async function main(argv = process.argv.slice(2)) {
1763
1782
  }
1764
1783
 
1765
1784
  if (args.interactive || (!args.goal && !args.resume && process.stdin.isTTY)) {
1766
- await startInteractiveCli(agentDefaults(args), { packageDir, packageVersion: packageJson.version });
1785
+ const webLaunch = await maybeEnsureDefaultWebApp(args, { commandCwd });
1786
+ await startInteractiveCli(agentDefaults(args), { packageDir, packageVersion: packageJson.version, webAppUrl: webLaunch.ok ? webLaunch.url : "" });
1767
1787
  return;
1768
1788
  }
1769
1789
 
@@ -1808,6 +1828,7 @@ export async function main(argv = process.argv.slice(2)) {
1808
1828
 
1809
1829
  const finalArgs = agentDefaults(args);
1810
1830
  if (!(await ensureDeepSeekKeyForOneShot(finalArgs))) process.exit(1);
1831
+ await maybeEnsureDefaultWebApp(finalArgs, { commandCwd });
1811
1832
  const config = loadConfig(finalArgs, { packageDir });
1812
1833
  await runAgent(config);
1813
1834
  }
@@ -772,10 +772,10 @@ function launchTitleLines(contentWidth) {
772
772
  return largeWidth <= contentWidth ? LARGE_LAUNCH_TITLE : COMPACT_LAUNCH_TITLE;
773
773
  }
774
774
 
775
- export function buildLaunchHeaderLines({ packageVersion = "", frame = 2, width = terminalWidth(), animated = true, language = cliLanguage } = {}) {
775
+ export function buildLaunchHeaderLines({ packageVersion = "", frame = 2, width = terminalWidth(), animated = true, language = cliLanguage, webAppUrl = "" } = {}) {
776
776
  const subtitle = t("launchSubtitle", language);
777
777
  const credit = t("launchCredit", language);
778
- const tagline = t("launchTagline", language);
778
+ const tagline = webAppUrl ? `webapp: ${webAppUrl}` : t("launchTagline", language);
779
779
  const version = packageVersion ? `v${packageVersion}` : "";
780
780
  const terminalColumns = Math.max(Number(width) || 80, 50);
781
781
  const contentWidth = Math.min(Math.max(terminalColumns - 8, 58), 112);
@@ -801,9 +801,9 @@ export function buildLaunchHeaderLines({ packageVersion = "", frame = 2, width =
801
801
  return boxLines.map((line) => `${indent}${line}`);
802
802
  }
803
803
 
804
- async function renderLaunchHeader(packageVersion = "", language = cliLanguage) {
804
+ async function renderLaunchHeader(packageVersion = "", language = cliLanguage, webAppUrl = "") {
805
805
  if (!useColor || process.env.AGINTIFLOW_NO_ANIMATION === "1") {
806
- console.log(buildLaunchHeaderLines({ packageVersion, animated: false, language }).join("\n"));
806
+ console.log(buildLaunchHeaderLines({ packageVersion, animated: false, language, webAppUrl }).join("\n"));
807
807
  return;
808
808
  }
809
809
 
@@ -812,7 +812,7 @@ async function renderLaunchHeader(packageVersion = "", language = cliLanguage) {
812
812
  let previousLineCount = 0;
813
813
  output.write(ansi.cursorHide);
814
814
  for (let frame = 0; frame < 18; frame += 1) {
815
- const lines = [...paddingLines, ...buildLaunchHeaderLines({ packageVersion, frame, animated: true, language })];
815
+ const lines = [...paddingLines, ...buildLaunchHeaderLines({ packageVersion, frame, animated: true, language, webAppUrl })];
816
816
  if (previousLineCount > 0) output.write(`\x1b[${previousLineCount}A`);
817
817
  output.write(lines.map((line) => `\r${ansi.clearLine}${line}`).join("\n"));
818
818
  output.write("\n");
@@ -3808,7 +3808,7 @@ async function runPrompt(prompt, state, packageDir, { approvalDepth = 0 } = {})
3808
3808
  return queuedAfterFinish;
3809
3809
  }
3810
3810
 
3811
- export async function startInteractiveCli(args = {}, { packageDir, packageVersion } = {}) {
3811
+ export async function startInteractiveCli(args = {}, { packageDir, packageVersion, webAppUrl = "" } = {}) {
3812
3812
  const state = createState(args);
3813
3813
  const detachProcessInterrupts = installProcessInterruptHandlers();
3814
3814
  setCliLanguage(state.language);
@@ -3822,7 +3822,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
3822
3822
  completer: commandCompleter,
3823
3823
  });
3824
3824
 
3825
- await renderLaunchHeader(packageVersion, state.language);
3825
+ await renderLaunchHeader(packageVersion, state.language, webAppUrl);
3826
3826
  printSystemLine(`${tr("project")}: ${process.cwd()}`);
3827
3827
  await maybeOnboardDeepSeekKey(state);
3828
3828
  printAgentMessage(tr("interactiveIntro"));
@@ -0,0 +1,121 @@
1
+ import { spawn } from "node:child_process";
2
+ import http from "node:http";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+
6
+ const DEFAULT_HOST = "127.0.0.1";
7
+ const DEFAULT_PORT = 3210;
8
+ const MAX_PORT_ATTEMPTS = 50;
9
+
10
+ function normalizeHost(host) {
11
+ return String(host || process.env.AGINTI_WEB_HOST || process.env.HOST || DEFAULT_HOST).trim() || DEFAULT_HOST;
12
+ }
13
+
14
+ function normalizePort(port) {
15
+ const parsed = Number(port || process.env.AGINTI_WEB_PORT || process.env.PORT || DEFAULT_PORT);
16
+ return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : DEFAULT_PORT;
17
+ }
18
+
19
+ function webUrl(host, port) {
20
+ return `http://${host}:${port}`;
21
+ }
22
+
23
+ function fetchHealth(host, port, timeoutMs = 450) {
24
+ return new Promise((resolve) => {
25
+ const req = http.get(`${webUrl(host, port)}/health`, { timeout: timeoutMs }, (res) => {
26
+ let body = "";
27
+ res.setEncoding("utf8");
28
+ res.on("data", (chunk) => {
29
+ body += chunk;
30
+ });
31
+ res.on("end", () => {
32
+ try {
33
+ const json = JSON.parse(body || "{}");
34
+ resolve(Boolean(res.statusCode === 200 && json.ok && (json.app === "agintiflow" || Number(json.port) === port)));
35
+ } catch {
36
+ resolve(false);
37
+ }
38
+ });
39
+ });
40
+ req.on("timeout", () => {
41
+ req.destroy();
42
+ resolve(false);
43
+ });
44
+ req.on("error", () => resolve(false));
45
+ });
46
+ }
47
+
48
+ function canListen(host, port) {
49
+ return new Promise((resolve) => {
50
+ const server = net.createServer();
51
+ server.once("error", () => resolve(false));
52
+ server.listen(port, host, () => {
53
+ server.close(() => resolve(true));
54
+ });
55
+ });
56
+ }
57
+
58
+ async function waitForHealth(host, port, timeoutMs = 7000) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ while (Date.now() < deadline) {
61
+ if (await fetchHealth(host, port, 350)) return true;
62
+ await new Promise((resolve) => setTimeout(resolve, 150));
63
+ }
64
+ return false;
65
+ }
66
+
67
+ export async function findReusableOrFreeWebPort({ host = DEFAULT_HOST, preferredPort = DEFAULT_PORT, attempts = MAX_PORT_ATTEMPTS } = {}) {
68
+ const startPort = normalizePort(preferredPort);
69
+ const normalizedHost = normalizeHost(host);
70
+ for (let offset = 0; offset < attempts; offset += 1) {
71
+ const port = startPort + offset;
72
+ if (port >= 65536) break;
73
+ if (await fetchHealth(normalizedHost, port)) {
74
+ return { port, host: normalizedHost, url: webUrl(normalizedHost, port), reused: true, available: false };
75
+ }
76
+ if (await canListen(normalizedHost, port)) {
77
+ return { port, host: normalizedHost, url: webUrl(normalizedHost, port), reused: false, available: true };
78
+ }
79
+ }
80
+ return { port: 0, host: normalizedHost, url: "", reused: false, available: false };
81
+ }
82
+
83
+ export async function ensureAgintiWebApp({
84
+ packageDir = process.cwd(),
85
+ cwd = process.cwd(),
86
+ host = DEFAULT_HOST,
87
+ preferredPort = DEFAULT_PORT,
88
+ language = "",
89
+ } = {}) {
90
+ if (process.env.AGINTI_NO_WEB_AUTO_START === "1" || process.env.AGINTIFLOW_NO_WEB_AUTO_START === "1") {
91
+ return { ok: false, disabled: true, url: "" };
92
+ }
93
+
94
+ const candidate = await findReusableOrFreeWebPort({ host, preferredPort });
95
+ if (!candidate.port) {
96
+ return { ok: false, error: `No available AgInTiFlow web port from ${normalizePort(preferredPort)}.`, url: "" };
97
+ }
98
+ if (candidate.reused) {
99
+ return { ok: true, reused: true, started: false, ...candidate };
100
+ }
101
+
102
+ const child = spawn(process.execPath, [path.join(packageDir, "web.js")], {
103
+ cwd,
104
+ detached: true,
105
+ stdio: "ignore",
106
+ env: {
107
+ ...process.env,
108
+ HOST: candidate.host,
109
+ PORT: String(candidate.port),
110
+ AGINTIFLOW_RUNTIME_DIR: cwd,
111
+ AGINTIFLOW_PACKAGE_DIR: packageDir,
112
+ ...(language ? { AGINTI_LANGUAGE: language } : {}),
113
+ },
114
+ });
115
+ child.unref();
116
+
117
+ const healthy = await waitForHealth(candidate.host, candidate.port);
118
+ return healthy
119
+ ? { ok: true, reused: false, started: true, pid: child.pid, ...candidate }
120
+ : { ok: false, error: `Started web process ${child.pid}, but ${candidate.url}/health did not become ready.`, url: "" };
121
+ }
package/web.js CHANGED
@@ -58,7 +58,7 @@ loadProjectEnv(baseDir);
58
58
  await ensureProjectSessionStorage(baseDir);
59
59
 
60
60
  const app = express();
61
- const port = Number(process.env.PORT || 3210);
61
+ let port = Number(process.env.PORT || 3210);
62
62
  const host = process.env.HOST || "127.0.0.1";
63
63
  const runs = new Map();
64
64
  const db = new WebDatabase(baseDir);
@@ -1337,12 +1337,39 @@ app.post("/api/runs/:sessionId/stop", async (req, res) => {
1337
1337
  });
1338
1338
 
1339
1339
  app.get("/health", (_req, res) => {
1340
- res.json({ ok: true, port });
1340
+ res.json({ ok: true, app: "agintiflow", port, url: `http://${host}:${port}` });
1341
1341
  });
1342
1342
 
1343
1343
  await fs.mkdir(sessionsDir, { recursive: true });
1344
1344
  await syncStoredSessions();
1345
1345
 
1346
- app.listen(port, host, () => {
1347
- console.log(`Website control agent UI running on http://${host}:${port}`);
1348
- });
1346
+ function listenOnPort(candidatePort) {
1347
+ return new Promise((resolve, reject) => {
1348
+ const server = app.listen({ port: candidatePort, host });
1349
+ server.once("error", (error) => reject(error));
1350
+ server.once("listening", () => resolve(server));
1351
+ });
1352
+ }
1353
+
1354
+ async function listenWithFallback(preferredPort, attempts = 50) {
1355
+ const startPort = Number.isInteger(Number(preferredPort)) && Number(preferredPort) > 0 ? Number(preferredPort) : 3210;
1356
+ let lastError = null;
1357
+ for (let offset = 0; offset < attempts; offset += 1) {
1358
+ const candidatePort = startPort + offset;
1359
+ if (candidatePort >= 65536) break;
1360
+ try {
1361
+ return await listenOnPort(candidatePort);
1362
+ } catch (error) {
1363
+ lastError = error;
1364
+ if (error?.code !== "EADDRINUSE") throw error;
1365
+ }
1366
+ }
1367
+ const error = new Error(`No available AgInTiFlow web port from ${startPort} after ${attempts} attempts.`);
1368
+ error.cause = lastError;
1369
+ throw error;
1370
+ }
1371
+
1372
+ export const webServer = await listenWithFallback(port);
1373
+ port = webServer.address()?.port || port;
1374
+ export const webUrl = `http://${host}:${port}`;
1375
+ console.log(`Website control agent UI running on ${webUrl}`);